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.

MCP stateless migration architecture overview 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:

AspectOld Spec (2025-03-26)New Spec (2026-07-28)
Session setupinitialize/initialized handshakeNone — requests are self-describing
Session trackingMcp-Session-Id headerRemoved entirely
Request metadataNegotiated at session start_meta field inline on every request
HTTP headersMinimalMcp-Protocol-Version, Mcp-Method, Mcp-Name
Load balancingSticky sessions requiredRound-robin / any strategy
State storageRedis, 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), or McpSdk 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}]}

Code diff showing _meta field addition 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:

HeaderValuePurpose
Mcp-Protocol-Version2026-07-28Declares which spec version this request uses
Mcp-Methode.g. tools/callMirrors the JSON-RPC method for routing
Mcp-Namee.g. get_weatherThe 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:

SDKPackageMin Version
TypeScript@modelcontextprotocol/sdk2.0.0
Pythonmcp-sdk2.0.0
Gogithub.com/modelcontextprotocol/go-mcp/v2v2.0.0
C#McpSdk2.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" });

Migration testing flow diagram Figure 3: Testing flow — run both old and new clients against your migrated server during the transition period.

Migration Checklist

#TaskVerified
1Remove session store (Redis/Memcached) connections
2Delete initialize/initialized handlers
3Add _meta parsing to all method handlers
4Validate Mcp-Protocol-Version header on incoming requests
5Set Mcp-Method and Mcp-Name headers on outgoing requests
6Remove sticky sessions from load balancer
7Remove Mcp-Session-Id handling from middleware
8Update SDK dependencies to 2.x
9Run integration tests with new SDK client
10Test backward compatibility (if supporting both versions)
11Update monitoring/alerting (remove session-count metrics)
12Deploy 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-26 but 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-worker template 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.