Stateless MCP: How the 2026-07-28 Spec Removes Sessions

Stateless MCP means every request carries what the server needs. What revision 2026-07-28 removed and added, shown with a stateful and a stateless Python server behind a two-instance proxy.

A client sends POST requests through a round-robin proxy to instance A and instance B. With the stateful server, instance A answers 200 and instance B answers 404 twice. With the stateless server, all six POSTs return 200.

Stateless MCP refers to the Model Context Protocol from revision 2026-07-28 on, where every request carries everything the server needs to answer it. MCP is the protocol that AI assistant apps use to call tools on a server, such as “create a playlist” or “read this file”. Until revision 2025-11-25, a client first introduced itself to one server, got a session ID back, and sent that ID with every later request, so only that one server could answer it. From 2026-07-28 there is no introduction and no session ID.

The one thing that changes for you: any instance of an MCP server can now answer any request, so a server that keeps data for a client has to move that data out of its own memory, into a handle the client sends back or into a store that every instance reads. Server authors carry most of that work. Client authors upgrade the SDK and keep the default settings.

The following client runs against such a server, on Python 3.13 with the official MCP Python SDK 2.3.0. It calls three tools and never introduces itself:

import asyncio
import json

from mcp import Client

async def main() -> None:
    url = "http://127.0.0.1:9000/mcp"   # the proxy in front of two instances

    async with Client(url) as client:   # no initialize, no Mcp-Session-Id header
        tools = await client.list_tools()
        created = await client.call_tool("create_playlist", {})
        playlist_id = json.loads(created.content[0].text)["playlist_id"]

        added = await client.call_tool(
            "add_track", {"playlist_id": playlist_id, "title": "Intro"}
        )
        tracks = json.loads(added.content[0].text)["tracks"]

        print([tool.name for tool in tools.tools], playlist_id, tracks)

asyncio.run(main())

The playlist_id the client gets back from create_playlist and sends to add_track is the handle. It replaces the session: the server mints it, the client passes it back, and whichever server instance gets the second call can look the playlist up.

This article covers what stateless MCP is, what the 2026-07-28 revision removed and added, and how to move a server and a client across, with a Python example throughout. Section 1 explains the two words with a playlist app. Section 2 and section 3 cover the old and the new protocol. Sections 4 to 7 run a stateful and a stateless server behind a proxy that spreads requests over two instances, and sections 8 and 9 are the migration steps for server and client authors.

1. Stateful and Stateless, in Plain Words

A protocol is stateful when the server has to remember something from an earlier request to answer the current one. It is stateless when each request carries everything the server needs, so the server keeps nothing between two requests. HTTP itself is stateless, and web apps put state back on top of it with cookies.

Let’s say a music app runs three copies of its API behind a load balancer. A load balancer is the machine in front of the copies that takes each incoming request and hands it to one of them; each copy is called an instance. Instance 1 keeps a user’s playlist in its own memory. The user’s next request lands on instance 2, which has no playlist for them and starts from scratch.

There are two fixes for that. Either the load balancer sends every request of one user back to instance 1, which is called sticky routing, or the playlist moves into a database that all three instances read.

Two panels. A stateful server holds session 7607a87a with the tracks Intro and Verse in its memory and receives an Mcp-Session-Id header. A stateless server receives a playlist_id in the request arguments and reads the tracks from a shared store.
A stateful server needs the session on one instance, and a stateless server needs only the playlist_id in the request.

A stateless protocol moves the state out of the memory of one server, into the request itself or into a store that every instance reads. We can then add or remove instances freely, and a restart loses no client data.

The two designs answer four questions differently, and a team meets all four in the week it starts a second instance.

QuestionStateful serverStateless server
What does the server remember between requests?Data per client, in the memory of one instanceNothing in the protocol. Application data lives in a shared store, and the request carries a handle or the full input
Which instance can answer a request?Only the instance that holds the stateAny instance
What do we do when we add instances?Add sticky routing or a shared session storeAdd the instances
What happens when an instance restarts?Its clients lose their stateNothing is lost; the next request goes to another instance

2. How MCP Kept a Session Until 2025-11-25

Until revision 2025-11-25, MCP was stateful in exactly the way section 1 describes. The client and the server exchange JSON-RPC 2.0 messages, where JSON-RPC is a small format for remote calls: each message is a JSON object with a method name, a params object and an id that matches a response to its request. Over HTTP, the Streamable HTTP transport puts all of that on one URL such as /mcp, and until 2025-11-25 that URL accepted both POST and GET.

2.1. The initialize Handshake

Every connection opened with an initialize request. The client sent the protocol version it speaks, its capabilities (the optional features it supports) and its name. The server answered with its own version, capabilities and name, and the client confirmed with a notifications/initialized message. The lifecycle page of that revision requires this handshake before any other request. This is what the request looked like:

{
  "jsonrpc": "2.0", "id": 1, "method": "initialize",
  "params": {
    "protocolVersion": "2025-11-25",
    "capabilities": {},
    "clientInfo": {"name": "raw", "version": "1"}
  }
}

The result listed what the server offers. A client that saw no resources entry here was not allowed to call resources/read:

{
  "jsonrpc": "2.0", "id": 1,
  "result": {
    "protocolVersion": "2025-11-25",
    "capabilities": {
      "prompts": {"listChanged": false},
      "resources": {"listChanged": false, "subscribe": false},
      "tools": {"listChanged": false}
    },
    "serverInfo": {"name": "playlist-stateful", "version": ""}
  }
}

Our server has one tool and no resources or prompts, and the SDK still lists all three groups. An entry here means the SDK can serve that kind of request; the client gets an empty list when it asks. The version is empty because we created the server as MCPServer(“playlist-stateful”), with a name and nothing else.

2.2. The Mcp-Session-Id Header

A session is a series of requests that the server treats as one conversation with one client. In revision 2025-11-25, a server could mint a session ID in the response to initialize, and the client then had to send it in the Mcp-Session-Id header of every later request. On the wire it looks like this, from a raw HTTP client talking to the stateful server of section 5:

initialize -> 200 {'mcp-session-id': '435dad788f51421187283a33893dc7ec'}
notifications/initialized -> 202 {}
tools/call -> 200 {}

The ID appears once, in the first response, and travels back in every request after it. Three rules said what happens around that header.

  • If a server requires a session and the header is missing, the server answers 400 Bad Request.
  • If the server has ended the session, it answers 404 Not Found, and the client has to start over with a new initialize.
  • A client that is done sends an HTTP DELETE with the header, and the server may answer 405 Method Not Allowed instead of ending the session.

The session IDs and playlist IDs in this article change from one output block to the next, because each block comes from its own run of the example project.

2.3. What Else the Server Had to Remember

The session was not the only thing. A server could answer a POST with a Server-Sent Events (SSE) stream, which is an HTTP response that stays open so the server can send several messages over it. A client could also open a stream of its own with a GET and receive messages nobody had asked for, such as a notification or a question for the user. And a client that lost a stream could ask for the missed messages back with a Last-Event-ID header. Each of these needs memory on the server, and the table says what.

Feature in 2025-11-25What the server has to keep
Mcp-Session-IdA session record per client, in the memory of one instance or in a shared store
GET streamAn open connection to one client, held by one instance
Last-Event-ID resumptionA store of the messages already sent
Server-to-client requests, such as asking the user a questionThe waiting request, until the client answers on the same connection

All of that is what breaks behind a load balancer. The MCP maintainers wrote up the cost in their post on the future of MCP transports: sessions force sticky routing, sticky routing blocks auto-scaling, and a load balancer has to open the JSON-RPC body to know where a request belongs. Section 7 shows the failure with real servers.

3. What Revision 2026-07-28 Changes

Revision 2026-07-28 takes the session out of the protocol. The Python SDK 2.0.0 came out the same day and supports it, and it keeps serving the earlier revisions from the same server. The table has one row per change, with the behavior before and after. The last column is the Specification Enhancement Proposal (SEP), which is the pull request where the maintainers worked that change out.

AreaUntil 2025-11-25In 2026-07-28SEP
Handshakeinitialize and notifications/initializedRemoved. Version, client info and capabilities travel in _meta of every request2575
Server identityPart of the initialize resultNew method server/discover, which every server must implement2575
SessionsMcp-Session-Id header, HTTP DELETERemoved. List endpoints return the same thing to every caller2567
Routing headersMCP-Protocol-Version onlyMCP-Protocol-Version and Mcp-Method on every POST, Mcp-Name for tool, resource and prompt calls; the server rejects a mismatch with the body2243
GET streamOptional HTTP GET endpointRemoved. subscriptions/listen opens a long-lived stream with a POST2575
ResumabilityLast-Event-ID and SSE event IDsRemoved. A broken stream loses the request, and the client sends it again with a new request ID2575
Server-to-client requestsRequests on an SSE streamAn input_required result that the client answers by retrying the request2322
Methodsping, logging/setLevel, notifications/roots/list_changedRemoved. The log level is set per request in _meta2575

Two cells need a line more. Server identity means the name and version of the server, which now come from server/discover and from the _meta of every result. List endpoints returning the same thing to every caller means tools/list, resources/list and prompts/list no longer vary by client, because there is no session left to vary them by.

Two sequence diagrams. Revision 2025-11-25 needs initialize, a result with Mcp-Session-Id, notifications/initialized, tools/call with the session header and a GET stream. Revision 2026-07-28 needs one tools/call request and one result.
Revision 2026-07-28 replaces the handshake and the session header with metadata in every request.

3.1. Every Request Carries Its Own Metadata

Each request now says which protocol version the client speaks and what it supports, so the server needs no memory of a handshake. Over HTTP the client repeats the version and the method in headers, so a load balancer can route on the headers without opening the body:

POST /mcp HTTP/1.1
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: create_playlist

Two of those headers go on every POST, and the third depends on the method.

  • MCP-Protocol-Version carries the version, such as 2026-07-28, and it has to match the version inside the body.
  • Mcp-Method carries the JSON-RPC method, such as tools/call.
  • Mcp-Name carries the tool name for tools/call, the resource URI for resources/read and the prompt name for prompts/get. Every other method sends no Mcp-Name.

The body of that POST carries the same version, plus the client’s name and capabilities, under three keys of _meta:

{
  "jsonrpc": "2.0", "id": 2, "method": "tools/call",
  "params": {
    "name": "create_playlist",
    "arguments": {},
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": {"name": "raw", "version": "1"},
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}

When a header and the body disagree, the server answers 400 Bad Request with error code -32020. We sent Mcp-Name: add_track with a body that calls create_playlist, and the SDK server answered:

400 {"jsonrpc":"2.0","id":1,"error":{"code":-32020,"message":"mcp-name header does not match the request body's 'name' parameter"}}

3.2. Asking the Server What It Speaks

A client that wants to know what a server supports before it sends a real request calls server/discover. Servers must implement it, clients may call it, and it returns the supported versions, the capabilities and the server’s name:

{
  "jsonrpc": "2.0", "id": 1,
  "result": {
    "resultType": "complete",
    "supportedVersions": ["2026-07-28"],
    "capabilities": {
      "prompts": {"listChanged": true},
      "resources": {"listChanged": true, "subscribe": true},
      "tools": {"listChanged": true}
    },
    "cacheScope": "private",
    "ttlMs": 0,
    "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "playlist-stateless", "version": ""}}
  }
}

Every result in the new revision carries a resultType. It is complete when this is the answer, and input_required when the server needs something from the client before it can finish (section 3.4). The fields ttlMs and cacheScope are cache hints (SEP 2549): ttlMs says how many milliseconds the result stays fresh, so 0 means do not cache it, and cacheScope: private means a shared cache between client and server must not store it.

3.3. One Endpoint, POST Only

The one URL survives, and the Streamable HTTP page of the new revision says it accepts POST only. The server answers each request with one JSON object, or with an SSE stream that belongs to that request alone and carries progress notifications before the final result. Closing the stream cancels the request. Four rules follow for a server that speaks only the new revision, and our SDK server did the first one when we tried a GET.

  • A GET or DELETE to the endpoint gets 405 Method Not Allowed.
  • An Mcp-Session-Id header is ignored, and the server never mints or echoes one.
  • A Last-Event-ID header is ignored, because streams are not resumable.
  • A client that wants change notifications, such as notifications/tools/list_changed, sends subscriptions/listen, and the response to that POST is a stream that stays open.

A subscriptions/listen stream is still held by one instance, the way the old GET stream was. The difference is that no session lives on that instance any more. If the stream breaks, the client opens a new one, and the load balancer can hand it to any instance.

3.4. Keeping Data Without a Session

Taking the session out of the protocol does not take the data out of the application. A server that needs data across calls returns a handle from one tool, and the client passes it back as an ordinary argument to later tools. SEP 2567 describes exactly the create_playlist() and add_track(playlist_id) pair we use in section 6.

The harder case is a server that needs an answer from the user in the middle of a call, such as “confirm this booking”. It cannot send a question on a stream any more. SEP 2322 turns that one exchange into four steps.

  1. The client sends its request, such as a tools/call that books a room.
  2. The server answers with a result whose resultType is input_required, an inputRequests object that says what it needs, and a requestState value holding whatever the server needs to pick the work up again.
  3. The client collects the input, from the user or from its own model, and sends the original request again with a new request ID, the same parameters, an inputResponses object and the requestState it was given.
  4. Any instance can answer the retry, because everything it needs is in that second request.

Note that requestState passes through the client, so the client can read it and change it. A server signs or encrypts it and checks it on the retry, the way we treat any value we hand to a caller and then trust again.

4. The Example Project

The example is a playlist service written twice, once as a stateful MCP server and once as a stateless one, plus a small proxy that spreads requests over two instances of either. Everything is in the mcp-stateless-server folder on GitHub.

  • stateful_server.py keeps each client’s playlist in memory, keyed by the session ID.
  • stateless_server.py returns a playlist_id handle and keeps the tracks in a SQLite file that both instances open. SQLite is the small database that ships with Python, so there is nothing to install.
  • proxy.py takes every request and sends it to instance A and instance B in turn.
  • client_test.py lists the tools, creates a playlist and adds three tracks, through the SDK’s Client class.
  • run_demo.sh starts two instances and the proxy and runs the client against the proxy.

It runs on Python 3.13 with the MCP Python SDK 2.3.0, uvicorn (the web server that runs the SDK’s app) and httpx (the HTTP client the proxy uses). Create one virtual environment, which is a folder holding one Python and the packages for one project, and install the requirements into it:

python3.13 -m venv .venv
.venv/bin/pip install -r requirements.txt   # on Windows: .venv\Scripts\pip install -r requirements.txt

With uv, a newer tool that replaces pip and venv in one program, the same two lines are uv venv –python 3.13 .venv and uv pip install –python .venv/bin/python -r requirements.txt.

5. The Stateful Server

This server shows the design the new revision replaces, and after this section you have seen a session work on one instance and fail without one. In the Python SDK, the class MCPServer holds the tools, and its method streamable_http_app() returns the web app that uvicorn runs. With stateless_http=False, the default, the SDK creates a session when a client sends initialize, hands out an Mcp-Session-Id, and answers 404 for an ID it does not know.

The one tool reads the session ID from the HTTP request and uses it as the key of a dictionary, so each client gets its own playlist:

import os
import sys

import uvicorn
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from mcp.server.mcpserver.exceptions import ToolError

INSTANCE = os.environ.get("INSTANCE", "A")
mcp = MCPServer("playlist-stateful")

playlists: dict[str, list[str]] = {}   # kept in the memory of this process

@mcp.tool()
def add_track(title: str, ctx: Context) -> dict:
    """Add a track to the playlist of the current session."""
    request = ctx.request_context.request
    session_id = request.headers.get("mcp-session-id") if request else None

    if session_id is None:
        raise ToolError("This server needs a session, but the request has none")

    tracks = playlists.setdefault(session_id, [])
    tracks.append(title)
    return {"instance": INSTANCE, "session": session_id[:8], "tracks": tracks}

if __name__ == "__main__":
    app = mcp.streamable_http_app(stateless_http=False)
    uvicorn.run(app, host="127.0.0.1", port=int(sys.argv[1]), log_level="warning")

The test client has to use the old handshake, so we start it with mode=”legacy”; the default mode auto would pick the new revision, and this server would then see no session. We start one instance and run the client against it:

.venv/bin/python stateful_server.py 9001 &
.venv/bin/python client_test.py http://127.0.0.1:9001/mcp legacy
tools: ['add_track']
{"instance": "A", "session": "7607a87a", "tracks": ["Intro"]}
{"instance": "A", "session": "7607a87a", "tracks": ["Intro", "Verse"]}
{"instance": "A", "session": "7607a87a", "tracks": ["Intro", "Verse", "Outro"]}

The playlist grows because all three calls carry the same session. A modern client against this same server sends no session at all, so the tool raises its error on every call:

{"error": "Error executing tool add_track: This server needs a session, but the request has none"}

6. The Stateless Server

Without a session the server cannot use the caller as a key, so every request has to say which playlist it means. After this section you have the pattern every stateless server uses: one tool hands out a handle, the other tools take it as an argument, and the data sits in a store every instance can read.

The same MCPServer class builds the app. The option stateless_http=True changes nothing for modern requests, which carry no session anyway; it only stops the SDK from handing a session to an old client that sends initialize. So what makes this server stateless is the handle and the shared store, not the flag.

import os
import sqlite3
import sys
import uuid

import uvicorn
from mcp.server import MCPServer

INSTANCE = os.environ.get("INSTANCE", "A")
DB_FILE = os.environ.get("PLAYLIST_DB", "playlists.db")
mcp = MCPServer("playlist-stateless")

def connect() -> sqlite3.Connection:
    db = sqlite3.connect(DB_FILE)
    db.execute("CREATE TABLE IF NOT EXISTS tracks (playlist_id TEXT, title TEXT)")
    return db

@mcp.tool()
def create_playlist() -> dict:
    """Create an empty playlist and return its handle."""
    return {"instance": INSTANCE, "playlist_id": uuid.uuid4().hex[:8]}

@mcp.tool()
def add_track(playlist_id: str, title: str) -> dict:
    """Add a track to the playlist identified by playlist_id."""
    with connect() as db:
        db.execute("INSERT INTO tracks VALUES (?, ?)", (playlist_id, title))
        rows = db.execute(
            "SELECT title FROM tracks WHERE playlist_id = ?", (playlist_id,)
        ).fetchall()

    return {"instance": INSTANCE, "playlist_id": playlist_id,
            "tracks": [row[0] for row in rows]}

if __name__ == "__main__":
    app = mcp.streamable_http_app(stateless_http=True)
    uvicorn.run(app, host="127.0.0.1", port=int(sys.argv[1]), log_level="warning")

The handle here is a random string that the server never checks, so any client can send any playlist_id and read the tracks behind it. If your handles point at user data, that is the first thing to fix: store the handles you issue, link each one to the caller, and give it an expiry. The protocol leaves the format and the lifetime of a handle to the server.

On the wire there is no initialize, no session header and no 202 for an initialized notification. A raw client calling server/discover and then one tool sees two POSTs and two results:

server/discover -> 200 {}
    {"jsonrpc":"2.0","id":1,"result":{"cacheScope":"private","capabilities":{...},"resultType":"complete","supportedVersions":["2026-07-28"],"ttlMs":0,...}}
tools/call -> 200 {}
    {"jsonrpc":"2.0","id":2,"result":{"content":[{"text":"{\n \"instance\": \"A\",\n \"playlist_id\": \"558c382d\"\n}","type":"text"}],"isError":false,"resultType":"complete",...}}

7. Two Instances Behind a Round-Robin Proxy

Both servers work on one instance, so the difference only shows with two. A round-robin proxy sends request 1 to instance A, request 2 to instance B, request 3 to A again, which is what a load balancer without sticky routing does. After this section you have seen the stateful server fail on the second request and the stateless one answer all of them.

The proxy cycles through the backend URLs and streams each response back, so SSE responses pass through without buffering:

BACKENDS = [f"http://127.0.0.1:{port}" for port in sys.argv[2:]]
next_backend = itertools.cycle(BACKENDS)
client = httpx.AsyncClient(timeout=30)
HOP_BY_HOP = {"host", "content-length", "connection", "transfer-encoding"}

async def forward(request: Request) -> StreamingResponse:
    backend = next(next_backend)
    headers = {k: v for k, v in request.headers.items() if k not in HOP_BY_HOP}

    upstream = client.build_request(
        request.method, backend + request.url.path,
        headers=headers, content=await request.body(),
    )
    response = await client.send(upstream, stream=True)
    print(f"{request.method} {request.url.path} -> {backend} ({response.status_code})")

    out_headers = {k: v for k, v in response.headers.items() if k not in HOP_BY_HOP}
    return StreamingResponse(response.aiter_raw(), response.status_code, out_headers)

The script run_demo.sh starts instance A on port 9001, instance B on port 9002 and the proxy on port 9000, then runs the client against the proxy. First the stateful server with the legacy client:

./run_demo.sh stateful
FAILED: MCPError: Session not found

--- proxy log
POST /mcp -> http://127.0.0.1:9001 (200)
POST /mcp -> http://127.0.0.1:9002 (404)
GET /mcp -> http://127.0.0.1:9001 (200)
POST /mcp -> http://127.0.0.1:9002 (404)
DELETE /mcp -> http://127.0.0.1:9001 (200)

The client never reached a tool call. Five requests went through the proxy, to A and B in turn, and here is what each one met.

  1. The initialize POST went to A, which created the session and answered 200.
  2. The notifications/initialized POST went to B, which has no such session, so 404.
  3. The GET that opens the legacy stream went to A, which still holds the session, so 200.
  4. The first real call, tools/list, went to B and got the second 404. The SDK client raised MCPError: Session not found, and the three add_track calls never ran.
  5. The DELETE that ends the session went to A and got 200.

Revision 2025-11-25 tells a client to start a new session after a 404. A client that does so begins again on whichever instance gets its next request, and loses whatever the first session held.

Now the stateless server with the modern client. The same three tool calls, and the playlist keeps growing across both instances:

./run_demo.sh stateless
tools: ['create_playlist', 'add_track']
{"instance": "B", "playlist_id": "3ec13115", "tracks": ["Intro"]}
{"instance": "A", "playlist_id": "3ec13115", "tracks": ["Intro", "Verse"]}
{"instance": "B", "playlist_id": "3ec13115", "tracks": ["Intro", "Verse", "Outro"]}

--- proxy log
POST /mcp -> http://127.0.0.1:9001 (200)
POST /mcp -> http://127.0.0.1:9002 (200)
POST /mcp -> http://127.0.0.1:9001 (200)
POST /mcp -> http://127.0.0.1:9002 (200)
POST /mcp -> http://127.0.0.1:9001 (200)
POST /mcp -> http://127.0.0.1:9002 (200)
A client sends requests through a round-robin proxy to instance A and instance B. With the stateful server, instance A answers 200 three times and instance B answers 404 twice, and the client fails. With the stateless server, all six POSTs return 200.
The legacy session only works on instance A, and the client fails, while all six stateless POSTs succeed on A and B.

All six POSTs succeeded on A and B in turn, because the playlist is in the shared file and the handle is in the request. Five of them are the calls the client makes: tools/list, create_playlist and three add_track calls. The sixth, the first in the log, is a probe that a client in auto mode sends to find out which revision the server speaks.

Note that the two runs compare the protocols, not the storage. A stateful server can keep its sessions in a shared store too, and then it also survives the proxy. What it cannot do is skip the session, so it still pays for a lookup on every request and for cleaning up after clients that never come back.

8. Migrating a Server

A server author ends this section with a server that answers both old and new clients, and with every piece of per-session data moved into a handle. The versioning page of the new revision names the three kinds of implementation: legacy (up to 2025-11-25), modern (2026-07-28 on), and dual-era (speaks both). The table says what happens for each pair.

ClientServerOutcome
ModernModernWorks
ModernLegacyFails
LegacyModernFails; the request is missing the required headers and gets 400 Bad Request
Dual-eraModernWorks, the client stays modern
Dual-eraLegacyWorks, the client falls back to initialize
LegacyDual-eraWorks, the server answers initialize and serves the client by the legacy revision

The last row is where most servers want to be while old clients exist. The Python SDK 2.x serves both eras from one app object and decides per request: a POST that calls initialize is served as legacy, and a POST with MCP-Protocol-Version: 2026-07-28 and the method headers is served as modern. Our stateless_server.py is already dual-era, because streamable_http_app(stateless_http=True) answers both, and an old client gets a working server with no session attached.

Decision tree. Server authors who use the session header or per-session data return a handle from a create tool and keep data in a store, and the others upgrade the SDK. Client authors pin mode 2026-07-28 when the server is known to be modern, and otherwise use the default mode auto.
Server authors replace session data with handles, and client authors keep the default auto mode unless the server era is known.

The five steps, each with what you have when it is done.

  1. Upgrade the SDK to a version that supports 2026-07-28, which is 2.0.0 or later for Python. Afterwards the server answers both eras, and nothing else has changed yet.
  2. Find every use of the session ID and of per-session data, and replace each with a handle that one tool returns and later tools receive as an argument. Afterwards no tool reads Mcp-Session-Id, and the data lives in a store every instance can open.
  3. Make tools/list, resources/list and prompts/list identical for every client. They no longer vary per connection, so a server that showed different tools to different clients now does that inside the tool, by argument.
  4. Replace server-initiated questions with the input_required result, and put whatever the retry needs into requestState, signed. Afterwards no tool waits on a stream for an answer.
  5. Make every tool safe to call twice, because a client whose stream broke sends the request again. add_track(playlist_id, title) as written adds a second copy on a retry; it is safe once it takes a request ID from the caller and skips an ID it has already written.

9. Migrating a Client

A client author ends this section with a client that talks to any server, old or new, and the SDK does most of it. The client finds out the server’s era by trying: it sends a modern request first, and if that comes back as 400 Bad Request it reads the body. A recognized modern JSON-RPC error means the server is modern and the request was wrong; anything else means the server is legacy, and the client falls back to initialize. The Client class of the Python SDK does all of this, so the decision left to us is its mode argument.

  1. Keep the default mode=”auto” when the server’s era is unknown. The client probes with a modern request and falls back to initialize for a legacy server. That is the one extra POST in the stateless run of section 7.
  2. Pin mode=”2026-07-28″ when the server is known to be modern, which saves the probe, and mode=”legacy” for a server that only knows the handshake.
  3. If you write requests by hand, send the version, client info and capabilities in _meta of every request, and the headers MCP-Protocol-Version, Mcp-Method and Mcp-Name on every POST. The SDK fills these in for you.
  4. Send a request again with a new request ID when its response stream breaks, because the server no longer replays lost messages.
  5. Respect the ttlMs of list results, and open subscriptions/listen only when you need change notifications.

Revision 2026-07-28 also deprecates three features. A deprecated feature stays in the specification and keeps working, and SEP 2596 gives it at least twelve months before a release may drop it.

Deprecated featureReplacement
Roots (SEP 2577)Tool parameters, resource URIs or server configuration
Sampling (SEP 2577)A direct integration with an LLM provider API
Logging (SEP 2577)stderr for stdio, or OpenTelemetry for structured logs
HTTP+SSE transport of 2024-11-05 (SEP 2596)Streamable HTTP

Logging is deprecated as a feature, and the one method that set its level, logging/setLevel, is removed, because the level now travels in _meta of each request. The stdio transport in the table is MCP’s other transport, where the client starts the server as a child process and the two talk over standard input and output, so a server there writes its diagnostics to stderr. OpenTelemetry is the open standard for logs, metrics and traces that most monitoring tools read.

10. Stateless MCP FAQs

10.1. Does the Stateless Revision Break Existing MCP Servers?

Only when a legacy client meets a server that speaks the modern revision alone, or the other way round. A dual-era server answers both, and the Python SDK 2.x serves earlier revisions from the same server, so we can upgrade servers first and clients later. The table in section 8 has every combination.

10.2. Does MCP Still Support Stateful Applications?

Yes, but the state moves out of the protocol. A tool returns a handle such as a playlist_id, and the client passes it back as an argument, as in section 6.

10.3. Does a Stateless MCP Server Keep No State at All?

No. The protocol keeps no state between requests; the application still has its data. In our example the playlists live in a SQLite file that both instances open, and the server process holds nothing in memory.

10.4. How Does Authentication Work Without Sessions?

The same way it works for any HTTP API, because authorization never lived in the MCP session. The client puts its token in the HTTP Authorization header of every POST, and any instance can check it without shared state. Revision 2026-07-28 changes some details of the OAuth flow around that token, and none of them depend on a session.

11. Conclusion

Stateless MCP means a request carries its own protocol version, capabilities and handles, so no server instance has to remember an earlier request. Revision 2026-07-28 removes initialize, Mcp-Session-Id, the GET stream and stream resumption, and adds server/discover, the routing headers and the input_required result.

The two runs behind the proxy show what that buys. The stateful server with a legacy client died on the second instance with Session not found; the stateless server with a modern client answered all six requests across both instances. For a server author the work is one job: per-session data moves into handles and a shared store. The rest is upgrading to the Python SDK 2.x, staying dual-era while old clients exist, and letting new clients run in the default auto mode. One thing to carry away: a tool that is safe to call once is not yet safe to call twice, and the new revision retries.

12. References

Drop me your questions related to stateless MCP in the comments.

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.