On May 21, 2026, the Model Context Protocol team locked the 2026-07-28 specification release candidate. The headline change is that MCP is now stateless at the protocol layer. The Mcp-Session-Id header is gone. The initialize and initialized handshake is gone. Sessions, in any protocol-visible sense, are gone.
I rewrote my own internal MCP server (the one that exposes blog search to the chatbot on this portfolio) over the weekend. The new server is half the code, sheds the in-memory session map, and scales horizontally without sticky routing. This post walks the build the same way I ran it, with the explicit state handle pattern that replaces session scope and the SDK changes that ship in TypeScript and Python.
What changed in the MCP 2026-07-28 spec?
The MCP 2026-07-28 spec removes the protocol-level session and replaces it with three things: per-request capability negotiation, a small set of new headers for routing, and an "explicit state handle" pattern for any workflow that genuinely needs cross-call state. The spec final ships on July 28, 2026 after a ten-week validation window.
Here are the changes that actually affect server authors.
| Change | What it replaces | Where it shows up |
|---|---|---|
Mcp-Session-Id removed | All session-scoped state | Drop the header. Use explicit handles in tool args. |
initialize / initialized handshake removed | Connection-lifetime capability exchange | Capabilities flow per request via _meta field |
New Mcp-Method, Mcp-Name headers | Body inspection for routing | Load balancers route on headers, no DPI required |
MCP-Protocol-Version: 2026-07-28 header | Version negotiation in handshake | Sent on every request |
| List endpoints session-independent | Per-session cacheable lists | tools/list cached across what used to be sessions |
| Tool input schemas: full JSON Schema 2020-12 | Limited subset | oneOf, anyOf, $ref, conditionals allowed |
The change you see in your code is the smallest. The change you see in your infra is the largest. A remote MCP server that previously needed sticky sessions, a shared session store, and deep packet inspection at the gateway can now run behind a plain round-robin load balancer.
Why did SEP-2567 remove sessions?
SEP-2567 removed sessions because, after more than a year in the spec, the abstraction never converged on a consistent meaning. ChatGPT created a fresh session for every individual tool call. Most desktop IDE clients created one at application launch and kept it for the process lifetime. Web clients created one per page load. Almost no client resumed a prior session after a disconnect.
Server authors did not control which scope they got. A Playwright MCP server that tied a browser instance to "the session" might keep it alive for the full chat (good), throw it away after every tool call (lossy), or share it across every conversation in the window (bug). The same server code produced three different bugs depending on the client.
The SEP also pointed at a quieter but bigger cost: list endpoints. Because tools/list could legally vary per session, clients could not cache it across sessions. An orchestrator that spawns ten subagents to research products in parallel had to call tools/list ten times against every connected server, even when the tool set was fixed at build time. That is O(subagents x servers) overhead on the hot path. Removing sessions makes the call O(servers): the orchestrator fetches each list once and every subagent reuses the cached result.
For my own server the rewrite paid for itself the moment I deleted the Map<sessionId, Transport> boilerplate from the TypeScript SDK. That map alone was 40 lines of dead-code lifecycle management. The new server handles every request independently.
How do you replace sessions with explicit state handles?
You replace sessions by adding a create_* tool that returns an opaque handle, then accepting that handle as a parameter on every subsequent tool that operates on the resource. Nothing about this is a protocol extension. From the wire's perspective the handle is a string in a tool result and a string in a tool argument.
Here is the canonical shopping-cart example from SEP-2567, transcribed against my own JSON-RPC trace.
// Client calls the creation tool. No session, no Mcp-Session-Id.
// Request:
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "create_basket", "arguments": {} } }
// Server mints a handle and returns it in structuredContent.
// Response:
{ "jsonrpc": "2.0", "id": 1,
"result": {
"content": [{ "type": "text", "text": "Created basket bsk_a1b2c3" }],
"structuredContent": { "basket_id": "bsk_a1b2c3" }
} }
// The model carries the handle forward on the next call.
// Request:
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": { "name": "add_item",
"arguments": { "basket_id": "bsk_a1b2c3", "sku": "shoes" } } }That is the whole pattern. The server owns the state, the client holds a name for it, and authorization is checked on every call.
The SEP gives six guidance rules that are worth memorizing because the SDKs do not enforce them:
- Handles must be opaque.
bsk_a1b2c3is fine.cart_user42_2026-03-11invites the model to guess them. - Possession is not authorization. Validate
(handle, auth_context)on every call. - Durability must be in the tool description. "Baskets expire after 24h idle" goes in the
create_basketdescription so the model sees it. - Expired handles return useful errors. "Basket bsk_a1b2c3 has expired" lets the model recover.
- Creation takes parameters.
create_context(cluster="staging")beatscreate_context()followed byset_cluster(ctx, "staging"). - Cleanup is available. Add
destroy_*andlist_*tools when it fits the domain.
I codified these as a small TypeScript helper that wraps the handle lifecycle. We will look at it next.
How do you build a stateless MCP server in TypeScript?
You build a stateless MCP server in TypeScript by setting sessionIdGenerator: undefined on the SDK's HTTP transport, exposing a create_* tool that returns a handle, and storing the handle in any backing store you like. The TypeScript SDK has supported this mode for over a year. The 2026-07-28 protocol simply makes it the only mode that can be negotiated.
Here is the smallest server that exposes the cart pattern from above.
// server.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { randomBytes } from 'node:crypto'
import { z } from 'zod'
type Basket = { id: string; items: string[]; owner: string; expiresAt: number }
const baskets = new Map<string, Basket>()
const HANDLE_TTL_MS = 24 * 60 * 60 * 1000
function mintHandle(): string {
return 'bsk_' + randomBytes(16).toString('base64url')
}
function getBasket(id: string, owner: string): Basket {
const cart = baskets.get(id)
if (!cart) throw new Error(`Basket ${id} not found`)
if (cart.owner !== owner) throw new Error(`Basket ${id} not found`)
if (cart.expiresAt < Date.now()) {
baskets.delete(id)
throw new Error(`Basket ${id} has expired`)
}
return cart
}
const server = new Server(
{ name: 'cart-server', version: '1.0.0' },
{ capabilities: { tools: {} } },
)
server.setRequestHandler('tools/list', async () => ({
tools: [
{
name: 'create_basket',
description: 'Create an empty basket. Returns basket_id. Baskets expire after 24h idle.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'add_item',
description: 'Add an item to a basket by basket_id.',
inputSchema: {
type: 'object',
required: ['basket_id', 'sku'],
properties: {
basket_id: { type: 'string' },
sku: { type: 'string' },
},
},
},
],
}))
server.setRequestHandler('tools/call', async (request, extra) => {
// The auth principal arrives on the request, NOT on a session.
const owner = extra.authInfo?.subject ?? 'anonymous'
switch (request.params.name) {
case 'create_basket': {
const id = mintHandle()
baskets.set(id, {
id,
items: [],
owner,
expiresAt: Date.now() + HANDLE_TTL_MS,
})
return {
content: [{ type: 'text', text: `Created basket ${id}` }],
structuredContent: { basket_id: id },
}
}
case 'add_item': {
const args = z
.object({ basket_id: z.string(), sku: z.string() })
.parse(request.params.arguments)
const cart = getBasket(args.basket_id, owner)
cart.items.push(args.sku)
cart.expiresAt = Date.now() + HANDLE_TTL_MS
return {
content: [
{ type: 'text', text: `Added ${args.sku} to ${cart.id} (${cart.items.length} items)` },
],
}
}
default:
throw new Error(`Unknown tool: ${request.params.name}`)
}
})
// Stateless transport. Note sessionIdGenerator: undefined.
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
})
await server.connect(transport)Three things to call out. First, sessionIdGenerator: undefined is the SDK's stateless mode. In the 2026-07-28 protocol version it is the only valid setting. Second, the auth principal arrives from extra.authInfo, which the SDK populates from the bearer token on the request, not from a session lookup. Third, the getBasket helper checks (handle, owner) together. A handle leaked into another user's chat is useless against this server because the owner check fails.
Behind a load balancer, the baskets map needs to live in shared storage (Redis, Postgres, DynamoDB). For my portfolio I used Upstash Redis from a Next.js route handler because the rest of the stack already depends on it, and the swap was a 12-line change. The point of the architecture is that any replica can serve any request.
How do you handle authentication and handle security?
You handle authentication by binding every handle to an auth principal and checking both on every call. The 2026-07-28 spec strengthens this by mandating iss parameter validation per RFC 9207, requiring application_type declaration during client registration, and tightening scope accumulation during step-up auth. The handle becomes the resource identifier and the auth context becomes the access check, the same way Google Doc IDs work.
The minimum check looks like this.
// auth.ts
import jwt from 'jsonwebtoken'
export type AuthInfo = { subject: string; scopes: string[] }
export async function verifyBearer(token: string): Promise<AuthInfo> {
const decoded = jwt.verify(token, process.env.JWT_PUBLIC_KEY!, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'mcp-cart-server',
}) as { sub: string; scope: string }
return {
subject: decoded.sub,
scopes: decoded.scope.split(' '),
}
}The TypeScript SDK calls this on every HTTP request and stashes the result in extra.authInfo. The tool handler reads it back, as in the create_basket and add_item cases above.
For unauthenticated servers, where the handle is necessarily a bearer token, SEP-2567 prescribes at least 128 bits of cryptographically secure entropy and a bounded lifetime. The 16-byte randomBytes(16).toString('base64url') call above gives 128 bits exactly. Do not derive handles from timestamps, sequence numbers, or any predictable input. Treat them like password-reset tokens.
The same posture applies to authenticated servers as a defense in depth. A handle that is both auth-bound and unguessable survives the obvious failure modes: a bug that forgets the auth check, a misconfigured proxy that strips the bearer token, an analytics pipeline that logs handles to a third-party tool.
How do you migrate an existing stateful MCP server?
You migrate an existing stateful MCP server by classifying it against the SEP-2567 backward-compatibility table, then applying the matching migration. The SEP authors surveyed 1000 open-source MCP servers. Here is the distribution they found, with what the migration actually looks like in each row.
| Server category | Share | Migration |
|---|---|---|
| No app-level use of session ID | 90.0 percent | Nothing. Just upgrade the SDK. |
Map<sessionId, Transport> boilerplate | 3.5 percent | Removed by the sessionless SDK transport. |
| Transport setup only (never read) | 2.8 percent | Delete one constructor option. |
| Session-keyed application state | 2.5 percent | Migrate to explicit handles. |
| Proxy / gateway sticky routing | 0.7 percent | Re-route on authenticated principal. |
| Auth binding (PKCE keyed on session) | 0.5 percent | Replace with server-generated nonce. |
The interesting case is the 2.5 percent that keep real per-session state. That is where you do the work this post is mostly about: convert each piece of session-scoped state into a tool-minted handle. Use the table from SEP-2567 to scope the work realistically before you touch code.
For stdio servers the migration is gentler. Stdio servers never had Mcp-Session-Id because there was no header transport, so the spec change does not break them mechanically. The SEP recommends migrating to explicit handles anyway, because process lifetime has the same undefined-scope problem (whether the process corresponds to one conversation or one app launch is up to the host).
For HTTP gateways that spawn one subprocess per session, the work is real. The gateway's correlation key needs to move from Mcp-Session-Id to a different scope. Authenticated principal is the most common replacement; a gateway-issued cookie is the fallback for unauthenticated traffic. This is a transport-layer concern, not a protocol concern, and the SEP intentionally leaves the design to gateway authors.
My own server fell into the "transport setup only" row. I had sessionIdGenerator: () => randomUUID() from a copied SDK example, but nothing in my handlers ever read the session ID. Deleting the option was the entire migration.
If you maintain an MCP server pinned in a remote agent's tool list, mirror this exercise. The MCP roadmap post calls out that 90 percent of servers will migrate by deleting code, not adding it. The other 10 percent should plan a real refactor against this table.
How do you verify your server is truly stateless?
You verify a stateless MCP server by running the protocol conformance suite against it, replaying a representative trace with the session-affinity removed, and load-balancing across at least two replicas without sticky routing. If any of those three reveals affinity, the server has hidden state.
The protocol conformance suite ships with the official SDK and runs as a CLI.
npx @modelcontextprotocol/conformance \
--server "http://localhost:8080/mcp" \
--protocol-version 2026-07-28 \
--include-stateless-checksThe --include-stateless-checks flag adds the suite's session-affinity tests. Each test issues a tools/call against one replica, then issues a follow-up against a different replica, and asserts the follow-up succeeds. If any test fails, the server has state that does not survive cross-replica calls.
For the load-balanced check, the smallest possible setup is a local docker-compose with two replicas behind nginx round-robin.
# docker-compose.yml
services:
mcp-1:
build: .
environment:
- REDIS_URL=redis://redis:6379
mcp-2:
build: .
environment:
- REDIS_URL=redis://redis:6379
redis:
image: redis:8-alpine
lb:
image: nginx:alpine
ports: ['8080:80']
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf# nginx.conf
events {}
http {
upstream mcp {
server mcp-1:3000;
server mcp-2:3000;
}
server {
listen 80;
location /mcp {
proxy_pass http://mcp;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
}Run the conformance suite against http://localhost:8080/mcp. If add_item works after create_basket even when the two calls hit different replicas, your handle-backed store is doing its job and your server is stateless.
For deeper context on how MCP composes with everything else in the agentic stack, see the Spring AI 2.0 MCP annotations tutorial, the Vercel AI SDK 5 to 6 migration guide, and Claude Skills vs MCP vs Projects.
For the original sources, see the MCP 2026-07-28 release candidate post, the full text of SEP-2567, the MCP roadmap analysis from The New Stack, and the SEP-1359 protocol-level sessions discussion.
Keep Reading
- Spring AI 2.0 MCP Annotations Tutorial. The JVM side of the same protocol, with the annotation surface that maps onto the new stateless model.
- Vercel AI SDK 5 to 6 Migration Guide. The TypeScript SDK that the v6 ToolLoopAgent uses to call your new stateless MCP server.
- Claude Skills vs MCP vs Projects. When to expose capability as MCP versus as a Claude Skill, now that the protocol surface has settled.
- Vercel AI Gateway Deep Dive. The provider routing layer that pairs with stateless MCP servers behind round-robin load balancers.
