Stateless MCP servers: what actually changed
MCP revision 2026-07-28 removed sessions and the initialize handshake. What that means for your reverse proxy, health checks, timeouts and auth.
What a stateless MCP server is
A stateless MCP server holds no per-client state between requests. Every request carries the protocol version, the client capabilities and the credentials the server needs to answer it, so any process on any machine can answer any request. MCP (Model Context Protocol, the wire format agents use to reach tools) made this a rule in revision 2026-07-28, which removed the initialize handshake and the HTTP session that sat under it.
That is the whole operational point. A server that keeps nothing per client can sit behind an ordinary load balancer with no session affinity, be restarted during a deploy without breaking clients, and run as four identical processes instead of one. A session-oriented server does none of those things without extra machinery.
The Model Context Protocol is a stateless protocol: all the information needed to process a request is contained in the request itself. A server processes each request independently; no state should be inferred from previous requests, even those on the same connection or stream.
Stateless does not mean your server stores nothing. Your database, your queue and your cache are all still there. It means the protocol carries no state on the connection, so the server must not treat a connection, a process or an open socket as a stand-in for "this client, mid-conversation".
What revision 2026-07-28 removed
2026-07-28 is the current revision of the specification as of August 2026. Compared with 2025-11-25, it removes five things that existed to support sessions.
- The
initializerequest and thenotifications/initializednotification. There is no handshake at all (SEP-2575). - The
Mcp-Session-Idheader, and session termination with HTTPDELETE(SEP-2567). - The standalone HTTP
GETstream that servers pushed notifications on. It is replaced bysubscriptions/listen, an ordinary POST whose response is a long-lived stream. - SSE (server-sent events) stream resumability. The
Last-Event-IDheader and per-event IDs are gone, so a broken stream loses the in-flight request and the client must re-issue it as a new request with a new request ID. ping,logging/setLevelandnotifications/roots/list_changed. Log level is now a per-request field,io.modelcontextprotocol/logLevelin_meta.
One method was added, and every server must implement it. server/discover returns the server's supported protocol versions, capabilities and identity in one call. It is the closest thing to a handshake that remains, and calling it is optional for clients.
Why the session transport was hard to run in production
In 2025-11-25 and earlier, a server could mint a session ID at initialization and return it in the Mcp-Session-Id header on the InitializeResult. The client then had to send that header on every later request. The negotiated protocol version and the client's capabilities lived in the server's memory, keyed by that ID. Each of those choices has an operational cost.
- A restart threw the session table away. The specification required the server to answer any request carrying a dead session ID with
404 Not Found, and required the client to start over with a newInitializeRequest. Every deploy became a reconnect event for every connected client. - A second replica did not know the first replica's sessions. Scaling out meant sticky routing at the load balancer, or a shared session store that every replica read on every request.
- The session table was memory that grew with idle clients.
DELETEwas optional, and clients that closed without sending it left entries behind. - List results could vary per connection, so caching in front of the server was unsafe.
Removing sessions removes all four at once. That is the change worth understanding before you touch any config.
What every request carries now
Each POST to the MCP endpoint stands alone. The protocol version and the client capabilities travel in the request body under _meta, and selected fields are mirrored into HTTP headers so an intermediary can route on them without parsing JSON.
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather
Authorization: Bearer <access token>
{
"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": {}
}
}
}io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities are required on every request. clientInfo is not required, though clients should send it. A request missing a required field is malformed, so the server must reject it with JSON-RPC error -32602 and HTTP 400 Bad Request.
The Mcp-Method header is required on every request. Mcp-Name is required on tools/call, resources/read and prompts/get. The header value must match the body, and a server that processes the body must reject a mismatch with 400 Bad Request and error code -32020, HeaderMismatch. That rule exists because a load balancer routing on the header and a server executing on the body are two different sources of truth. If you route or rate-limit on these headers, check MCP-Protocol-Version first: earlier revisions never validated header against body, so on those versions the header value is not trustworthy.
Version disagreement is now an ordinary per-request error instead of a failed handshake. A server that does not implement the requested version answers 400 Bad Request with error -32022, UnsupportedProtocolVersion, and lists what it does support in data.supported. The client picks one from that list and retries.
Where the state went: tokens, cursors, subscriptions
State did not disappear. It moved into places you can see and log.
Credentials move into every request. There is no session to attach an identity to, so the access token rides on each HTTP call and is validated each time. Details are in the authentication section below.
Cursors have to carry their own position. Pagination on tools/list, resources/list, prompts/list and resources/templates/list uses an opaque cursor string, and clients must not parse or modify it. On a single-process server it was common to keep the offset in memory, keyed by the session. With no session, the cursor must be enough for any replica to resume the listing, so encode the position inside the cursor and sign it, or keep it in storage every replica shares. An invalid cursor should return -32602. Sign it because an opaque cursor is still client-supplied input that your code decodes and trusts.
Subscriptions belong to a request, not to a connection. A client that wants change notifications sends subscriptions/listen with a filter naming the types it wants: toolsListChanged, promptsListChanged, resourcesListChanged and resourceSubscriptions. The server replies with notifications/subscriptions/acknowledged and holds that response stream open. If the stream drops, the server keeps nothing, and the client re-sends subscriptions/listen to get it back.
Cross-call application state becomes an explicit handle. When a server genuinely must remember something between calls, the specification's answer is a server-minted identifier passed back as an ordinary tool argument. It appears in the tool schema, it can be logged, and it is never implied by the connection. A server with real per-user data behind it, such as a self-hosted MCP email server, uses this pattern instead of a session: the mailbox or draft identifier is a tool argument, so any replica can pick up the next call.
Deployment: reverse proxy, timeouts, health checks
The MCP endpoint is one path that accepts POST. Most traffic is a short request and a JSON response, which any proxy handles. The exception is the streaming response, where proxy defaults work against you. This is the part that changes when you move from a laptop demo to an MCP server running on a VPS.
location /mcp {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}proxy_buffering off matters because nginx buffers proxied responses by default, which holds SSE events until a buffer fills or the response ends. The specification also asks servers to send X-Accel-Buffering: no on SSE responses, and nginx honours that header, so a correct server tells your proxy the right thing on its own. Set the directive too, because that is the half you control.
proxy_read_timeout defaults to 60 seconds. A subscriptions/listen stream that sits quiet for longer than that is closed by nginx, not by your server, so your logs show a healthy process and your client shows a dropped stream. Raise it on the MCP location only, not on the whole server. Servers are also encouraged to send an SSE comment line (a line beginning with a colon) as a keep-alive during quiet periods, which stops intermediaries from timing the stream out at all.
Caddy needs less. It buffers partially by default for wire efficiency and flushes immediately when the response carries Content-Type: text/event-stream, so streaming works without extra directives.
mcp.example.com {
reverse_proxy 127.0.0.1:8080 {
health_uri /healthz
health_interval 10s
}
}Note what that health check points at. Do not aim an active check at the MCP endpoint with GET, because a server implementing only this revision answers 405 Method Not Allowed to GET and DELETE, and Caddy's default health method is GET. The proxy would then mark a perfectly healthy backend as down. Serve a plain path such as /healthz for the proxy, and check the protocol separately with a POST.
curl -sS https://mcp.example.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
-d '{"jsonrpc":"2.0","id":"health-1","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'A 200 carrying a supportedVersions list means the process is up and speaking the protocol. A 404 with JSON-RPC error -32601 means the process is up but does not serve server/discover, which every 2026-07-28 server must implement. A 400 with -32022 means your checker asked for a version this build does not support, which is exactly what you want to catch after a dependency upgrade. Open source nginx has no active health checks, so use passive max_fails and fail_timeout on the upstream and run the protocol check from your monitoring instead.
A rolling restart now costs you the requests in flight and nothing else. Drain, let open POSTs finish, start the new process, and clients re-issue whatever failed. The one thing you still drop is any open subscriptions/listen stream, because that stream is a live connection to one specific process. Statelessness removed session affinity. It did not remove connection affinity for a stream that is open right now, and no routing rule fixes that. A client can tell the difference: a stream that ends with the empty subscriptions/listen result closed gracefully, and a stream that ends without one dropped, which the client may treat as a reason to reconnect.
Caching becomes possible for the first time. Results from the list methods now carry ttlMs and cacheScope, and cacheScope: "public" tells shared intermediaries they may cache the response. That is only safe because list results no longer vary per connection, which is a direct consequence of removing sessions.
Why authentication changes when there is no session
With a session, it was tempting to authenticate once at initialize and then treat the session ID as proof for everything after it. A session ID used that way is a bearer credential with no audience, no expiry and no revocation path, minted by your own server. Removing sessions removes that shortcut, and the replacement is stricter.
A protected MCP server acts as an OAuth 2.1 resource server. Every HTTP request from the client must carry Authorization: Bearer <access token>, and the server validates the token on every request. Validation includes the audience: the server must confirm the token was issued for it specifically, per RFC 8707 (Resource Indicators for OAuth 2.0), and must not accept or pass on tokens meant for anything else. Clients request the right audience by sending the resource parameter with the server's canonical URI.
Discovery runs off a challenge. When a request arrives with no usable token, the server answers 401 Unauthorized.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="files:read"The client reads resource_metadata, fetches that document (RFC 9728, OAuth 2.0 Protected Resource Metadata, which MCP servers must implement), finds the authorization server and runs the flow. A valid token with too few permissions gets 403 Forbidden with error="insufficient_scope" and the scopes required for that operation.
Two consequences for how you run it. Token validation now happens on every request rather than once per session, so a network round trip to an introspection endpoint per call will show up in your latency: prefer tokens you can verify locally against a signature, an audience and an expiry, or cache the validation result for a short window keyed by the token. And because there is no session holding an identity, authorization must be computed from the token on each call. That is more honest than the session model was, and it pairs with the wider practice of keeping credentials out of the agent process, which is covered in keeping secrets out of an AI agent.
What is true of this revision, and what is not
Everything above describes revision 2026-07-28. It does not describe MCP forever, and it does not describe the server you deployed last year.
Clients and servers on 2025-11-25 and earlier still speak the handshake model. The specification calls those revisions legacy, and calls the per-request-metadata revisions modern. A server that supports only this revision, meeting an older client, should answer 405 Method Not Allowed to GET or DELETE on the MCP endpoint, ignore any Mcp-Session-Id header without minting or echoing one, and ignore Last-Event-ID because streams are not resumable. A dual-era server may serve both on one endpoint: a request carrying modern _meta is served statelessly, and an initialize request selects the older session semantics.
So check the revision string before you trust any of this. If your SDK still sends initialize, sessions are still real for your deployment and the session-shaped problems above are still yours to manage. The same applies on the client side: an agent process on your own box, such as the setup in running a coding agent on a VPS, is only stateless in this sense if the library it uses speaks a modern revision. Read the version your runtime negotiates, then read the matching revision of the specification, and treat this page as describing one named revision rather than the protocol in general.
FAQ
Does a stateless MCP server mean I cannot store anything?
No. Stateless describes the protocol, not your application. Databases, queues and caches all work exactly as before. What changes is that state spanning several calls must be referenced by an explicit identifier the client passes on each request, such as a server-minted handle in a tool argument. What you may not do is infer context from the connection: the specification says a server must not rely on prior requests over the same connection to establish capabilities, protocol version or client identity, because every request supplies those in _meta.
Do I still need sticky sessions on my load balancer?
Not for ordinary requests. Under revision 2026-07-28 each POST carries its own protocol version, capabilities and credentials, so any replica can answer any request and round-robin is fine. The one long-lived thing left is the subscriptions/listen response stream, which is a single open connection to a single process. It ends when that process ends, and the client re-sends subscriptions/listen to re-establish it. That is connection lifetime rather than session affinity, and no routing rule prevents it.
What happened to Mcp-Session-Id and the HTTP GET stream?
Both were removed in revision 2026-07-28, under SEP-2567 and SEP-2575. A server implementing only this revision should answer 405 Method Not Allowed to GET and DELETE on the MCP endpoint, and should ignore an Mcp-Session-Id header rather than echo one back. Server-initiated change notifications now travel on the response stream of a subscriptions/listen request instead of a standalone GET stream. Servers that must keep serving older clients implement the earlier revision's behavior alongside this one.
How do I health check an MCP server with no handshake?
Use two levels. Point the proxy's active check at a plain HTTP path your application serves, because a GET to the MCP endpoint correctly returns 405 and would mark a healthy backend as down. Then check the protocol itself by POSTing server/discover, which every 2026-07-28 server must implement, and assert that the reply is HTTP 200 and lists a protocol version your clients use. A 404 with JSON-RPC error -32601 means the process is running but not serving that method, and a 400 with -32022 means the version you asked for is not supported by that build.