MCP Stateless Migration Guide: 2026-07-28 Spec
A step-by-step guide to migrating your MCP server from the session-based model to the new stateless 2026-07-28 spec. Includes before/after code, checklists, and FAQ.
Last week, I migrated three production MCP servers—two on AWS and one on Google Cloud Run—from the old session-based protocol to the new stateless 2026-07-28 spec. The result: I deleted 400 lines of session management code, removed a Redis cluster, and finally got true round-robin load balancing working without sticky sessions. The whole migration took about four hours per service. This guide is the playbook I wish I’d had before starting.
Figure 1: Before vs. after architecture — session-pinned servers on the left, stateless round-robin on the right.
What Changed in 2026-07-28
The MCP 2026-07-28 specification introduced the most significant protocol change since MCP’s initial release. Here’s what’s different:
| Aspect | Old Spec (2025-03-26) | New Spec (2026-07-28) |
|---|---|---|
| Session setup | initialize/initialized handshake | None — requests are self-describing |
| Session tracking | Mcp-Session-Id header | Removed entirely |
| Request metadata | Negotiated at session start | _meta field inline on every request |
| HTTP headers | Minimal | Mcp-Protocol-Version, Mcp-Method, Mcp-Name |
| Load balancing | Sticky sessions required | Round-robin / any strategy |
| State storage | Redis, Memcached, etc. | Not needed for protocol |
If you’re new to MCP, check out our MCP Protocol Explained primer first.
Prerequisites
Before you begin:
- A working MCP server on the old spec (2025-03-26 or 2025-06-18)
- Updated SDKs:
@modelcontextprotocol/sdk@^2.0.0(TypeScript),mcp-sdk>=2.0.0(Python),go-mcp/v2(Go), orMcpSdk 2.x(C#) - Access to your load balancer configuration
- A staging environment to test the migration
Step-by-Step Migration
Step 1: Remove Your Session Store
The old spec required maintaining session state—typically in Redis or an in-memory store. Since 2026-07-28 makes every request self-describing, this infrastructure is no longer needed for the MCP protocol layer.
Before (TypeScript):
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
interface McpSession {
clientCapabilities: ClientCapabilities;
serverCapabilities: ServerCapabilities;
protocolVersion: string;
createdAt: number;
}
async function getSession(sessionId: string): Promise<McpSession | null> {
const data = await redis.get(`mcp:session:${sessionId}`);
return data ? JSON.parse(data) : null;
}
async function createSession(sessionId: string, session: McpSession): Promise<void> {
await redis.set(`mcp:session:${sessionId}`, JSON.stringify(session), 'EX', 3600);
}
After (TypeScript):
// Session store completely removed.
// No Redis dependency, no session creation, no session lookup.
// Each request carries its own context via _meta.
Delete your Redis/Memcached connection code, session interfaces, and any TTL cleanup jobs. If you use Redis for other purposes (caching tool results, rate limiting), keep those—just remove the session-management logic.
Step 2: Remove the Initialize Handler
The initialize/initialized handshake no longer exists. Remove the handler and any capability negotiation that happened at session start.
Before (Python):
from mcp.server import McpServer
server = McpServer()
@server.method("initialize")
async def handle_initialize(params: dict) -> dict:
client_caps = params.get("capabilities", {})
session_id = generate_session_id()
await store_session(session_id, {
"client_capabilities": client_caps,
"protocol_version": params["protocolVersion"],
})
return {
"protocolVersion": "2025-03-26",
"capabilities": {
"tools": {"listChanged": True},
"resources": {"subscribe": True},
},
"serverInfo": {"name": "my-server", "version": "1.0.0"},
}
@server.method("initialized")
async def handle_initialized(params: dict) -> None:
# Session is now active
pass
After (Python):
from mcp.server import McpServer
server = McpServer(
name="my-server",
version="1.0.0",
# Capabilities are now declared in server config,
# not negotiated per-session
capabilities={
"tools": {"listChanged": True},
"resources": {"subscribe": True},
},
)
# No initialize/initialized handlers needed.
# The server is ready to handle tool calls immediately.
Step 3: Add _meta to Every Request
In the stateless model, each request must include a _meta field with protocol context that was previously established during initialization.
Client-side request (TypeScript):
// Before: bare request after handshake
const oldRequest = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "get_weather",
arguments: { city: "Tokyo" },
},
};
// After: self-describing request with _meta
const newRequest = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "get_weather",
arguments: { city: "Tokyo" },
_meta: {
protocolVersion: "2026-07-28",
capabilities: {
tools: { listChanged: true },
},
},
},
};
Server-side parsing (Python):
@server.method("tools/call")
async def handle_tool_call(params: dict) -> dict:
meta = params.get("_meta", {})
protocol_version = meta.get("protocolVersion", "2026-07-28")
client_capabilities = meta.get("capabilities", {})
# Use capabilities inline — no session lookup required
tool_name = params["name"]
arguments = params["arguments"]
result = await execute_tool(tool_name, arguments)
return {"content": [{"type": "text", "text": result}]}
Figure 2: The _meta field carries context that was previously negotiated in the initialize handshake.
Step 4: Add HTTP Headers to Requests
The new spec introduces three required HTTP headers for Streamable HTTP transport:
| Header | Value | Purpose |
|---|---|---|
Mcp-Protocol-Version | 2026-07-28 | Declares which spec version this request uses |
Mcp-Method | e.g. tools/call | Mirrors the JSON-RPC method for routing |
Mcp-Name | e.g. get_weather | The tool/resource name for observability |
Client-side (TypeScript):
const response = await fetch("https://mcp.example.com/mcp", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Mcp-Protocol-Version": "2026-07-28",
"Mcp-Method": "tools/call",
"Mcp-Name": "get_weather",
},
body: JSON.stringify(request),
});
Server-side validation (TypeScript):
import express from "express";
const app = express();
app.post("/mcp", (req, res) => {
const protocolVersion = req.headers["mcp-protocol-version"];
const method = req.headers["mcp-method"];
if (!protocolVersion) {
return res.status(400).json({
jsonrpc: "2.0",
error: { code: -32020, message: "Missing Mcp-Protocol-Version header" },
id: null,
});
}
if (protocolVersion !== "2026-07-28") {
return res.status(400).json({
jsonrpc: "2.0",
error: { code: -32020, message: `Unsupported protocol version: ${protocolVersion}` },
id: null,
});
}
// Process the request...
});
Step 5: Update Load Balancer Configuration
With session state gone, you no longer need sticky sessions. Update your load balancer to use round-robin or least-connections.
NGINX — Before:
upstream mcp_servers {
ip_hash; # Sticky sessions
server mcp-1:8080;
server mcp-2:8080;
server mcp-3:8080;
}
NGINX — After:
upstream mcp_servers {
# Round-robin (default) — no sticky sessions needed
server mcp-1:8080;
server mcp-2:8080;
server mcp-3:8080;
}
AWS ALB: Remove the stickiness configuration from your target group. In Terraform:
resource "aws_lb_target_group" "mcp" {
# Remove this block entirely:
# stickiness {
# type = "lb_cookie"
# cookie_duration = 3600
# }
health_check {
path = "/health"
}
}
Step 6: Test with Updated SDKs
Each official SDK has released a 2.x version supporting the 2026-07-28 spec:
| SDK | Package | Min Version |
|---|---|---|
| TypeScript | @modelcontextprotocol/sdk | 2.0.0 |
| Python | mcp-sdk | 2.0.0 |
| Go | github.com/modelcontextprotocol/go-mcp/v2 | v2.0.0 |
| C# | McpSdk | 2.0.0 |
Run your existing integration tests against the new SDK. The SDKs handle _meta injection and header management automatically when configured:
import { McpClient } from "@modelcontextprotocol/sdk/client";
const client = new McpClient({
transport: "streamable-http",
url: "https://mcp.example.com/mcp",
// No session management — the SDK handles _meta and headers
});
const result = await client.callTool("get_weather", { city: "Tokyo" });
Figure 3: Testing flow — run both old and new clients against your migrated server during the transition period.
Migration Checklist
| # | Task | Verified |
|---|---|---|
| 1 | Remove session store (Redis/Memcached) connections | ☐ |
| 2 | Delete initialize/initialized handlers | ☐ |
| 3 | Add _meta parsing to all method handlers | ☐ |
| 4 | Validate Mcp-Protocol-Version header on incoming requests | ☐ |
| 5 | Set Mcp-Method and Mcp-Name headers on outgoing requests | ☐ |
| 6 | Remove sticky sessions from load balancer | ☐ |
| 7 | Remove Mcp-Session-Id handling from middleware | ☐ |
| 8 | Update SDK dependencies to 2.x | ☐ |
| 9 | Run integration tests with new SDK client | ☐ |
| 10 | Test backward compatibility (if supporting both versions) | ☐ |
| 11 | Update monitoring/alerting (remove session-count metrics) | ☐ |
| 12 | Deploy to staging and verify round-robin distribution | ☐ |
Backward Compatibility: Supporting Both Specs
If you have existing clients that haven’t migrated yet, you can support both the old and new spec simultaneously using version detection:
app.post("/mcp", async (req, res) => {
const protocolVersion = req.headers["mcp-protocol-version"];
const sessionId = req.headers["mcp-session-id"];
if (protocolVersion === "2026-07-28") {
// New stateless path
return handleStatelessRequest(req, res);
} else if (sessionId) {
// Legacy session-based path
return handleSessionRequest(req, res, sessionId);
} else {
// No version header and no session — assume initialize handshake
return handleLegacyInitialize(req, res);
}
});
This dual-mode approach lets you migrate servers first, then coordinate client upgrades at their own pace. We documented a similar strategy in our MCP at Scale with Google Cloud case study.
Common Pitfalls
1. Forgetting to Remove Session Pinning
The most common mistake I’ve seen: you migrate the server code but forget the load balancer. Your server is stateless, but requests still pin to one instance. You won’t notice until an instance dies and traffic doesn’t redistribute.
Fix: Explicitly verify that requests distribute across instances. Tail logs on all instances simultaneously during testing.
2. Header Mismatch Errors (-32020)
Error code -32020 means the Mcp-Protocol-Version header doesn’t match what the server expects, or required headers are missing. This commonly happens when:
- A CDN or reverse proxy strips custom headers
- The client SDK isn’t configured for the new transport
- You’re sending
Mcp-Protocol-Version: 2025-03-26but the request body uses_meta
Fix: Check your proxy/CDN configuration to ensure Mcp-* headers pass through. Add these to your allowed headers list.
3. Breaking Existing Clients
If you remove initialize support without a backward-compatibility layer, older clients will get connection errors with no useful message.
Fix: Deploy the dual-mode handler shown above. Log when legacy clients connect so you can track migration progress and eventually sunset the old path.
Deployment Targets
The stateless model works especially well with:
- Cloudflare Workers — The official
@modelcontextprotocol/cloudflare-workertemplate deploys a fully stateless MCP server at the edge. No cold start penalty for session lookup. - Google Cloud Run — Scale to zero, scale to thousands. Each request is independent. See our detailed case study.
- AWS Lambda + Function URL — No ALB needed for simple deployments. Each invocation handles one request.
Benefits After Migration
Once you’ve completed the migration, you’ll see immediate operational improvements:
- True round-robin load balancing — No more hot instances from session pinning
- Serverless-friendly — Scale to zero with no session store to maintain
- No Redis dependency — One less piece of infrastructure to operate and pay for
- Transparent failover — If an instance dies, the next request goes elsewhere automatically
- Simplified debugging — Each request is self-contained; no need to reconstruct session history
- Lower latency — No initialize round-trip on first connection
FAQ
Q: Do I need to migrate immediately? A: No. The old spec still works, and the dual-mode approach lets you transition gradually. However, new SDKs default to 2026-07-28, so new integrations will expect it.
Q: What about server-sent events (SSE) and streaming?
A: SSE still works in the new spec. The difference is that each SSE connection is established per-request with full context in _meta, rather than relying on a session established earlier.
Q: Can I still maintain server-side state for my application? A: Absolutely. The protocol is stateless, but your application can still use databases, caches, and state stores for business logic. The change only removes protocol-level session state.
Q: What if my tool calls depend on previous context? A: Pass context explicitly in tool arguments or use a conversation/thread ID in your application layer. The protocol no longer maintains implicit session context.
Q: How do I handle authentication without sessions? A: Use standard HTTP auth mechanisms (Bearer tokens, API keys) on every request. This was already the recommended pattern even with the old spec.
Have questions about your migration? Check our MCP protocol explainer for foundational concepts, or see the Google Cloud scale case study for a production-scale example.


