If you wrote an MCP server a few months ago and it’s suddenly looking a little old-fashioned…welcome to the club!
The latest MCP specification update (2026-07-28) includes a number of changes: a new extensions framework, OAuth/OpenID authorization updates, a formal deprecation policy, and many more. But perhaps the biggest change is the removal of connection-level sessions and initialization handshakes. Basically, sessions are out, stateless requests are in.
Under the previous approach (2025-11-25), an initialization handshake was mandatory, but assigning a session ID and persisting per-client state was an implementation choice, not a requirement of the protocol itself. With the acceptance of SEP-2567 (explicit state handles) and SEP-2575 (stateless MCP), the protocol drops session support entirely. Now, each request is self-contained and context must be passed explicitly in request metadata and tool call parameters.
Why does this matter? Under the older model, for servers that chose a stateful pattern, the server had to “remember” which client it was talking to. That meant creating sessions, storing transports in memory, cleaning them up, and ensuring that load balancers routed subsequent requests back to the same server instance. However, with MCP 2026-07-28, session support is no longer available in any form.
For circa-2025 MCP servers built around session IDs, this change requires maintainers to restructure their MCP state management implementation. To see how this works in practice, I pulled out one of my early MCP server attempts - a stateful one - and tried to migrate it to the new stateless model.
Before
Here’s the relevant extract of code I started with, corresponding to MCP 2025-11-25. The complete source code is available for viewing, I updated it slightly to work with the current stable release of the MCP TypeScript SDK (v1.29):
async function main() {
const port = parseInt(process.env.PORT || '3000', 10);
const app = express();
app.use(express.json());
const transports: Record<string, StreamableHTTPServerTransport> = {};
app.all('/mcp', async (req: Request, res: Response) => {
try {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
let transport: StreamableHTTPServerTransport | undefined;
// check for existing session
// or create new one
if (sessionId && transports[sessionId]) {
transport = transports[sessionId];
}
else if (isInitializeRequest(req.body)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (newSessionId) => {
transports[newSessionId] = transport!;
},
});
transport.onclose = () => {
if (transport?.sessionId) {
delete transports[transport.sessionId];
}
};
// bind a fresh server to this transport session
const server = createMcpServer();
await server.connect(transport);
}
if (!transport) {
// return 400
// ...
}
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('[mcp-error]', error);
if (!res.headersSent) {
// return 500
// ...
}
}
});
app.listen(port, '0.0.0.0', () => {
console.error(`[server] MCP Server listening at http://0.0.0.0:${port}/mcp`);
});
}In the session-based model, every client first calls initialize. The server creates a transport, assigns it a session ID, stores it in memory, and returns that session ID to the client. Every subsequent request must include the same Mcp-Session-Id header so the server can recover the correct transport and continue processing requests.
For the client, using this server is a three-step dance.
-
Initialize.
bash curl -i -X POST http://localhost:3000/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "curl-test", "version": "1.0.0" } } }' -
Extract the session ID from the response:
bash HTTP/1.1 200 OK mcp-session-id: 0e015839-a6a1-4857-b3e8-62e757307e43 event: message data: {"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"users-mcp","version":"1.0.0"}},"jsonrpc":"2.0","id":1} -
Attach the session ID to subsequent tool requests:
bash curl -X POST http://localhost:3000/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "Mcp-Session-Id: 0e015839-a6a1-4857-b3e8-62e757307e43" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "list-users", "arguments": {} } }'
Because every client is tied to a server-managed session, requests must continue reaching the same instance. In practice, that usually means sticky sessions or a shared session store such as Redis.
After
Under MCP 2026-07-28, the server no longer “remembers” clients between requests. Instead, every request contains the information required to process it. This implies two changes:
-
Client metadata (like protocol version and client capabilities,) moves from
initializeto request metadata (_meta). -
Any application state that needs to survive across requests must be represented explicitly - for example, as opaque handles returned by one tool call and supplied to the next.
The updated server code uses the current beta release of the MCP TypeScript SDK (v2.0) and now becomes significantly simpler:
async function main() {
const port = parseInt(process.env.PORT || '3000', 10);
const app = express();
const mcpHandler = createMcpHandler(createMcpServer, {
// change to 'reject' to block legacy clients
legacy: 'stateless',
});
const nodeHandler = toNodeHandler(mcpHandler);
app.all('/mcp', async (req: Request, res: Response) => {
try {
await nodeHandler(req, res);
} catch (error) {
console.error('[mcp-error]', error);
if (!res.headersSent) {
// return 500
// ...
}
}
});
app.listen(port, '0.0.0.0', () => {
console.error(`[server] MCP Server listening at http://0.0.0.0:${port}/mcp`);
});
}This version makes a few changes:
- It replaces the
@modelcontextprotocol/sdkpackage with the newer@modelcontextprotocol/serverpackage. - It uses
createMcpHandler()andtoNodeHandler()instead of constructing aStreamableHTTPServerTransportmanually. - The transport registry, session lookup, UUID generation and cleanup callbacks disappear completely.
- A fresh
McpServerinstance is created for each request, allowing the handler to remain independent of previous requests. - Protocol negotiation and legacy request support are handled automatically by the SDK.
For the client, context must be passed in every request via _meta. Here’s an example:
curl -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Method: tools/call" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Name: list-users" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "curl-test",
"version": "1.0.0"
},
"io.modelcontextprotocol/clientCapabilities": {}
},
"name": "list-users",
"arguments": {}
}
}'Client metadata and protocol information are now transferred with each request in _meta, rather than during initialization. API keys or OAuth tokens were already supplied on every HTTP request and remain in the authorization headers, not _meta. Resource identifiers continue to live in request parameters or tool arguments.
This makes things much simpler, as each request is now self-contained. Also, because the protocol layer no longer maintains state, ordinary HTTP load balancing works normally. For example:
apiVersion: v1
kind: Service
metadata:
name: mcp-server
spec:
selector:
app: mcp-server
ports:
- port: 80
targetPort: 3000
type: LoadBalancer
# no session affinity required
sessionAffinity: NoneRemember that statelessness doesn’t imply idempotency: tool calls can still have side effects. If your server needs to maintain continuity to avoid these effects, you can use durable shared state (a database, a cache) or explicit handles passed between calls.
Compatibility
You might be wondering: if this is actually stateless, why is there still an Mcp-Session-Id in the list of allowed headers?
The reason is compatibility. The new stateless approach in MCP 2026-07-28 is not a breaking change; existing deployments keep working and handlers created with the new SDKs can serve both 2025-era and 2026-era deployments. This is achieved through the additional legacy parameter to createMcpHandler().
-
When
legacyis set tostateless, the handler accepts both 2025-era and 2026-era requests. Modern 2026 clients use the new stateless protocol directly. However, for legacy 2025 clients, the handler serves requests through a stateless fallback layer; it does not preserve the original initialize/session flow. -
When
legacyis set toreject, the handler only accepts connections from clients using the 2026 protocol. Clients attempting to connect with the older protocol will see this message:
HTTP/1.1 400 Bad Request
{"jsonrpc":"2.0","error":{"code":-32022,"message":"Unsupported protocol version: 2025-11-25","data":{"supported":["2026-07-28"],"requested":"2025-11-25"}},"id":1}If you actually need to keep serving stateful legacy clients, the new handler won’t work for that. In this case, you will need two handlers running in parallel: your original stateful handler for legacy requests (which requires the Mcp-Session-Id header), and a new strict handler with legacy set to reject for modern clients only. Read more about compatibility.
Summary
If you’re looking to adopt the new stateless approach in MCP, here’s a quick summary of the main changes to be aware of:
| Aspect | 2025-11-25 (Stateful) | 2026-07-28 (Stateless) |
|---|---|---|
| Session management | Server stores sessions in memory | No protocol session state |
| Client metadata | Client sends in initialize, then stored on server |
Client sends in _meta on every request |
| Server lifecycle | One server bound to each transport session | One server instance created for each request |
| Routing | Sticky (required only if sessions are used) | Any instance |
| Cross-request application state | May be tied to a protocol session | Passed explicitly or resolved through durable storage or opaque handles |
If you’d like to see the differences in approach for yourself, the GitHub repository has the same server implemented in two branches: mcp-2025-11-25 and mcp-2026-07-28 branches. Switch between them or use this diff to see the change in session-management code, and why the stateless implementation is simpler to deploy and scale.