Tutorial: Migrate Your MCP Server to the 2026-07-28 Stateless Spec
Practical guide to migrate a production MCP server to the 2026-07-28 spec: remove Mcp-Session-Id, stateless Streamable HTTP, load balancer setup, breaking changes checklist, and test procedures.
Tutorial: Migrate Your MCP Server to the 2026-07-28 Stateless Spec
The MCP 2026-07-28 spec does not ask you to rewrite your tools — it asks you to rewrite the transport layer. This tutorial walks you through audit to production with TypeScript and Python code.
On July 28, 2026, the Model Context Protocol ships its stateless revision: end of the initialize handshake, removal of the Mcp-Session-Id header, and mandatory Mcp-Method and Mcp-Name headers on Streamable HTTP. If you read our analysis of the 2026 stateless MCP spec for enterprise agents, you know the why. This article covers the how — with testable code snippets, load balancer configuration, a breaking changes checklist, and reproducible test procedures.
This tutorial targets teams that already run an MCP server in production — often built with our create an MCP server in TypeScript in 30 minutes or deploy an MCP AI agent in 20 minutes guides. You do not start from scratch: you migrate transport conventions, not business logic.
What actually changes for your server
Before opening your editor, align on vocabulary. Under 2025-11-25, a remote MCP client established a session, received an Mcp-Session-Id, and every follow-up request had to hit the same server instance. Under 2026-07-28, each request is self-contained: protocol version, client identity, and capabilities travel in _meta; the load balancer can route round-robin.
| Element | Before (2025-11-25) | After (2026-07-28) |
|---|---|---|
| Handshake | initialize + notifications/initialized | Removed (SEP-2575) |
| Session | Mcp-Session-Id header | Removed (SEP-2567) |
| LB routing | Sticky sessions required | Round-robin possible |
| HTTP headers | Optional | Mcp-Method, Mcp-Name required (SEP-2243) |
| Discovery | Via initialize | server/discover RPC |
| Tasks | Experimental core | Extension with new lifecycle |
| JSON Schema | Limited draft | Full JSON Schema 2020-12 (SEP-2106) |
From initial audit to production deployment: audit, breaking changes, SDK, transport, load balancer, tests, canary, cutover
Phase 1 — Audit your existing MCP stack
Start with a factual inventory. Without it, you will discover a breaking change in production on a Friday evening.
Audit checklist (30 minutes)
Run these commands on your repo and infrastructure:
# Find MCP session dependencies
rg -n "Mcp-Session-Id|sessionId|StreamableHTTPServerTransport" --type ts --type py
# Legacy initialize handshake
rg -n "initialize|notifications/initialized" src/
# Deprecated HTTP+SSE transport
rg -n "SSEServerTransport|sse" src/
# Experimental 2025-11-25 Tasks API
rg -n "tasks/list|experimental.*tasks" src/
# Legacy error code -32002
rg -n "\-32002" src/
For each MCP server, note:
- Transport: stdio, Streamable HTTP, or legacy HTTP+SSE (migrate first).
- Session dependencies: Redis, sticky cookie, Kubernetes affinity.
- Connected clients: Cursor, Claude Desktop, n8n, custom SDK.
- SDK version:
@modelcontextprotocol/sdk≥ 2026-07-28 RC compatible.
Four axes: breaking changes, stateless transport, infrastructure, validation
Quick decision matrix
Remote HTTP server, MCP session, experimental Tasks: three questions to prioritize actions
- Local stdio only → SDK upgrade + schema validation; no load balancer.
- Streamable HTTP with sessions → full transport refactor + infra.
- Legacy HTTP+SSE → migrate to Streamable HTTP before or during stateless cutover.
Phase 2 — TypeScript migration: from 2025-11-25 to stateless
We start from a server like the one in our TypeScript tutorial, adapted for HTTP with sessions. Here is a simplified legacy version:
// ❌ BEFORE — 2025-11-25 session pattern (illustrative excerpt)
import express from 'express';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
const app = express();
app.use(express.json());
const sessions = new Map<string, StreamableHTTPServerTransport>();
app.post('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId) {
const transport = new StreamableHTTPServerTransport('/mcp', res);
const server = createMcpServer();
await server.connect(transport);
sessions.set(transport.sessionId!, transport);
return;
}
const transport = sessions.get(sessionId);
if (!transport) {
res.status(404).json({ error: 'Unknown session' });
return;
}
await transport.handleRequest(req, res);
});
2026-07-28 stateless version
// ✅ AFTER — stateless Streamable HTTP (2026-07-28)
import express, { Request, Response } from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
const PROTOCOL_VERSION = '2026-07-28';
function createMcpServer(): McpServer {
const server = new McpServer(
{ name: 'crm-connector', version: '2.0.0' },
{ capabilities: { tools: {} } }
);
server.registerTool(
'search_contacts',
{
title: 'Search CRM contacts',
description: 'Full-text search in enterprise CRM',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', minLength: 1 },
limit: { type: 'integer', minimum: 1, maximum: 50, default: 10 },
},
required: ['query'],
},
},
async ({ query, limit = 10 }) => {
const results = await crmSearch(query, limit);
return {
content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
structuredContent: results,
};
}
);
// Explicit handle pattern for business state
server.registerTool(
'init_export_session',
{
title: 'Initialize export session',
description: 'Creates an export_id for subsequent export_chunk calls',
inputSchema: { type: 'object', properties: {} },
},
async () => {
const exportId = await createExportSession();
return {
content: [{ type: 'text', text: `export_id: ${exportId}` }],
structuredContent: { export_id: exportId },
};
}
);
return server;
}
const app = express();
app.use(express.json());
// Mandatory discover endpoint (SEP-2575)
app.get('/mcp/discover', (_req: Request, res: Response) => {
res.json({
protocolVersions: [PROTOCOL_VERSION, '2025-11-25'],
serverInfo: { name: 'crm-connector', version: '2.0.0' },
capabilities: { tools: {} },
});
});
app.post('/mcp', async (req: Request, res: Response) => {
const protocolVersion = req.headers['mcp-protocol-version'] as string;
const mcpMethod = req.headers['mcp-method'] as string;
const mcpName = req.headers['mcp-name'] as string | undefined;
if (protocolVersion !== PROTOCOL_VERSION) {
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32602, message: 'UnsupportedProtocolVersionError' },
id: null,
});
return;
}
// SEP-2243: header/body consistency
const bodyMethod = req.body?.method as string | undefined;
if (mcpMethod && bodyMethod && mcpMethod !== bodyMethod) {
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32602, message: 'Header/body method mismatch' },
id: req.body?.id ?? null,
});
return;
}
if (bodyMethod === 'tools/call') {
const toolName = req.body?.params?.name as string;
if (mcpName && toolName && mcpName !== toolName) {
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32602, message: 'Header/body tool name mismatch' },
id: req.body?.id ?? null,
});
return;
}
}
if (!req.body?.params?._meta) {
req.body.params = {
...req.body.params,
_meta: {
'io.modelcontextprotocol/protocolVersion': PROTOCOL_VERSION,
'io.modelcontextprotocol/clientInfo': {
name: req.headers['user-agent'] ?? 'unknown-client',
version: '1.0.0',
},
},
};
}
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless: no session
});
const server = createMcpServer();
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3000, () => console.log('Stateless MCP on :3000/mcp'));
Key diff points:
- Remove the session
Mapand allMcp-Session-Idlogic. - Validate
Mcp-Method/Mcp-Nameheaders vs JSON-RPC body. - Add
GET /mcp/discoverfor compatibility probing. - Business state via explicit
export_id, not transport session.
Updating to JSON Schema 2020-12
SEP-2106 allows oneOf, anyOf, and $ref/$defs. Example for a polymorphic tool:
server.registerTool(
'update_entity',
{
inputSchema: {
type: 'object',
oneOf: [
{
type: 'object',
properties: {
entity_type: { const: 'contact' },
contact_id: { type: 'string' },
email: { type: 'string', format: 'email' },
},
required: ['entity_type', 'contact_id', 'email'],
},
{
type: 'object',
properties: {
entity_type: { const: 'deal' },
deal_id: { type: 'string' },
stage: { type: 'string', enum: ['lead', 'qualified', 'won'] },
},
required: ['entity_type', 'deal_id', 'stage'],
},
],
},
},
async (args) => {
return { content: [{ type: 'text', text: 'OK' }] };
}
);
Add a CI step validating schemas against JSON Schema 2020-12 with ajv v8+.
Phase 3 — Python migration: FastMCP to stateless
If your stack is Python (often FastMCP or the official SDK), the logic is identical. Here is a stateless FastAPI server:
# server_stateless.py — MCP 2026-07-28 (illustrative excerpt)
from fastapi import FastAPI, Request, Response, HTTPException
from mcp.server.fastmcp import FastMCP
PROTOCOL_VERSION = "2026-07-28"
mcp = FastMCP("inventory-connector", json_response=True)
@mcp.tool()
async def check_stock(sku: str, warehouse: str = "main") -> dict:
"""Check stock quantity for a SKU in a warehouse."""
qty = await db_get_stock(sku, warehouse)
return {"sku": sku, "warehouse": warehouse, "quantity": qty}
@mcp.tool()
async def reserve_stock(sku: str, quantity: int, reservation_id: str | None = None) -> dict:
"""Reserve stock. Pass reservation_id on follow-up calls."""
if reservation_id is None:
reservation_id = await create_reservation(sku, quantity)
else:
await confirm_reservation(reservation_id)
return {"reservation_id": reservation_id, "status": "held"}
app = FastAPI()
@app.get("/mcp/discover")
async def discover():
return {
"protocolVersions": [PROTOCOL_VERSION],
"serverInfo": {"name": "inventory-connector", "version": "2.0.0"},
"capabilities": {"tools": {}},
}
@app.post("/mcp")
async def handle_mcp(request: Request):
protocol_version = request.headers.get("mcp-protocol-version")
mcp_method = request.headers.get("mcp-method")
mcp_name = request.headers.get("mcp-name")
if protocol_version != PROTOCOL_VERSION:
raise HTTPException(status_code=400, detail="UnsupportedProtocolVersionError")
body = await request.json()
if mcp_method and body.get("method") and mcp_method != body["method"]:
raise HTTPException(status_code=400, detail="Header/body method mismatch")
if body.get("method") == "tools/call":
tool_name = body.get("params", {}).get("name")
if mcp_name and tool_name and mcp_name != tool_name:
raise HTTPException(status_code=400, detail="Header/body tool name mismatch")
return await mcp.handle_stateless_request(request, body)
Install a compatible SDK version:
pip install "mcp>=1.10.0" # verify exact version in official docs
Phase 4 — Load balancer configuration (nginx)
One major stateless win: no protocol-mandated sticky sessions. Production-ready nginx config:
# /etc/nginx/conf.d/mcp-upstream.conf
upstream mcp_backend {
least_conn;
server mcp-1.internal:3000 max_fails=3 fail_timeout=30s;
server mcp-2.internal:3000 max_fails=3 fail_timeout=30s;
server mcp-3.internal:3000 max_fails=3 fail_timeout=30s;
keepalive 32;
}
map $http_mcp_method $mcp_rate_zone {
default "mcp_default";
"tools/call" "mcp_tools";
"tools/list" "mcp_list";
}
limit_req_zone $binary_remote_addr zone=mcp_default:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=mcp_tools:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=mcp_list:10m rate=60r/s;
server {
listen 443 ssl http2;
server_name mcp.example.com;
ssl_certificate /etc/ssl/mcp/fullchain.pem;
ssl_certificate_key /etc/ssl/mcp/privkey.pem;
location /mcp/discover {
proxy_pass http://mcp_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_cache mcp_discover_cache;
proxy_cache_valid 200 60s;
}
location /mcp {
limit_req zone=$mcp_rate_zone burst=20 nodelay;
proxy_pass http://mcp_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# NO sticky session — that is the point
proxy_read_timeout 120s;
proxy_send_timeout 120s;
client_max_body_size 4m;
}
}
MCP client → round-robin load balancer → N stateless instances → business APIs, with OAuth/OIDC upstream
CDN cache for tools/list (SEP-2549)
tools/list responses can carry ttlMs and cacheScope. Server-side TypeScript:
return {
tools: toolDefinitions,
_meta: {
'io.modelcontextprotocol/cache': {
ttlMs: 300_000,
cacheScope: 'shared',
},
},
};
Configure your CDN or nginx proxy_cache to respect these TTLs — less instance load, lower client latency.
Phase 5 — Breaking changes: complete checklist
Check each item before production cutover.
Transport and sessions
- Remove all
initialize/notifications/initializedlogic - Remove
Mcp-Session-Idheader on client and server - Remove Redis / Map MCP session store at protocol level
- Disable sticky sessions on load balancer
- Migrate legacy HTTP+SSE to Streamable HTTP
- Add
GET /mcp/discover(or equivalentserver/discoverRPC)
Headers and routing
- Require
MCP-Protocol-Version: 2026-07-28 - Send
Mcp-MethodandMcp-Nameon every Streamable HTTP POST - Reject header/body mismatches (400, not 500)
- Configure rate limiting by
Mcp-Methodat gateway
Application state
- Replace session state with explicit handles (
workflow_id, etc.) - Document handles in tool descriptions (the model must see them)
- Migrate elicitation to
InputRequiredResult+requestState(SEP-2322)
API and schemas
- Migrate
inputSchema/outputSchemato JSON Schema 2020-12 - Replace
-32002error matching with-32602(SEP-2164) - Migrate experimental Tasks to Tasks extension (remove
tasks/list) - Plan Roots / Sampling / Logging replacement (deprecated, 12-month minimum)
OAuth / security
- Implement
issvalidation (SEP-2468) — high priority - Verify Dynamic Client Registration with OIDC
application_type(SEP-837) - Test refresh tokens and step-up auth (SEPs 2207, 2350)
Observability
- Propagate
traceparent/tracestatein_meta(SEP-414) - Replace
logging/setLevelwith OpenTelemetry or structured stderr - Dashboards: p95 latency by
Mcp-Method, 4xx vs 5xx rate
Audit ~2d, SDK+transport ~5d, load balancer ~3d, tests ~4d, OAuth ~3d, deployment ~2d — order of magnitude for a medium server
Phase 6 — Test procedures
Manual test with MCP Inspector
npx @modelcontextprotocol/inspector \
--url https://mcp-v2.example.com/mcp \
--header "MCP-Protocol-Version: 2026-07-28"
Verify in Inspector:
server/discoverreturns2026-07-28.tools/listworks without a prior session.tools/callon each critical tool returns a valid result.- Two consecutive calls can hit different instances (test via
X-Debug-Instanceheader in dev).
CI test suite (Vitest + fetch)
// tests/mcp-stateless.integration.test.ts
import { describe, it, expect } from 'vitest';
const MCP_URL = process.env.MCP_TEST_URL ?? 'http://localhost:3000/mcp';
const HEADERS = {
'Content-Type': 'application/json',
'MCP-Protocol-Version': '2026-07-28',
};
describe('MCP 2026-07-28 stateless', () => {
it('discover announces correct version', async () => {
const res = await fetch(`${MCP_URL.replace('/mcp', '')}/mcp/discover`);
const data = await res.json();
expect(data.protocolVersions).toContain('2026-07-28');
});
it('tools/list without session', async () => {
const res = await fetch(MCP_URL, {
method: 'POST',
headers: { ...HEADERS, 'Mcp-Method': 'tools/list' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/list',
params: { _meta: { 'io.modelcontextprotocol/protocolVersion': '2026-07-28' } },
}),
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.result.tools.length).toBeGreaterThan(0);
});
it('rejects header/body mismatch', async () => {
const res = await fetch(MCP_URL, {
method: 'POST',
headers: { ...HEADERS, 'Mcp-Method': 'tools/call', 'Mcp-Name': 'wrong_tool' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'search_contacts', arguments: { query: 'test' } },
}),
});
expect(res.status).toBe(400);
});
it('stateless tools/call across independent requests', async () => {
for (let i = 0; i < 5; i++) {
const res = await fetch(MCP_URL, {
method: 'POST',
headers: {
...HEADERS,
'Mcp-Method': 'tools/call',
'Mcp-Name': 'search_contacts',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: i,
method: 'tools/call',
params: {
name: 'search_contacts',
arguments: { query: 'acme' },
_meta: { 'io.modelcontextprotocol/protocolVersion': '2026-07-28' },
},
}),
});
expect(res.status).toBe(200);
}
});
});
GitHub Actions integration:
# .github/workflows/mcp-migration-test.yml
name: MCP Stateless Migration Tests
on:
pull_request:
paths: ['src/mcp/**', 'tests/mcp/**']
jobs:
integration:
runs-on: ubuntu-latest
services:
mcp:
image: ghcr.io/your-org/crm-mcp:${{ github.sha }}
ports: ['3000:3000']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22' }
- run: npm ci
- run: npm run test:mcp
env:
MCP_TEST_URL: http://localhost:3000/mcp
Post-migration load test
Validate round-robin holds load without sticky sessions:
hey -n 1000 -c 50 -m POST \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/list" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
https://mcp-v2.example.com/mcp
Success criteria: error rate < 0.1%, p95 latency ≤ baseline + 15%.
Phase 7 — Canary deployment and rollback
Four-step cutover strategy
- Week 1: deploy
mcp-v2alongsidemcp-v1(legacy 2025-11-25). - Week 2: route internal clients (CI, staging) to v2; monitor 72 h.
- Week 3: canary 10% production traffic via header or subdomain.
- Week 4: 100% cutover; keep v1 read-only 2 weeks for rollback.
Rollback plan
Keep the old Docker image tagged mcp:2025-11-25-final. If error rate exceeds 1% post-cutover:
kubectl rollout undo deployment/mcp-server
Document the rollback window (max 30 minutes) in your incident runbook.
Phase 8 — Client-side MCP migration
Server migration alone is not enough: your clients (Cursor, n8n, custom agents) must stop sending Mcp-Session-Id and adopt the new headers. Here is a minimal TypeScript fetch wrapper for internal agents:
// lib/mcpClient2026.ts — minimal stateless client
const PROTOCOL = '2026-07-28';
export async function mcpCall<T>(
baseUrl: string,
method: string,
params: Record<string, unknown>,
name?: string
): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'MCP-Protocol-Version': PROTOCOL,
'Mcp-Method': method,
};
if (name) headers['Mcp-Name'] = name;
const body = {
jsonrpc: '2.0',
id: crypto.randomUUID(),
method,
params: {
...params,
_meta: {
'io.modelcontextprotocol/protocolVersion': PROTOCOL,
'io.modelcontextprotocol/clientInfo': { name: 'bovo-agent', version: '2.0.0' },
},
},
};
const res = await fetch(`${baseUrl}/mcp`, { method: 'POST', headers, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`MCP HTTP ${res.status}: ${await res.text()}`);
const data = await res.json();
if (data.error) throw new Error(data.error.message);
return data.result as T;
}
For n8n, upgrade the MCP Client node to a 2026-07-28-compatible version and verify credentials no longer include session parameters. Test a simple tools/list → tools/call workflow before re-enabling production workflows.
For Cursor and Claude Desktop, application updates usually suffice — Tier 1 clients adopt the spec within the ten-week post-RC window. Your local stdio servers benefit from automatic client updates without infrastructure changes.
Phase 9 — Kubernetes deployment without sticky sessions
In containerized production, stateless MCP simplifies horizontal scaling. Example Deployment + HPA:
# k8s/mcp-server.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: mcp-server
spec:
replicas: 3
selector:
matchLabels: { app: mcp-server }
template:
metadata:
labels: { app: mcp-server }
spec:
containers:
- name: mcp
image: ghcr.io/your-org/crm-mcp:2026-07-28
ports: [{ containerPort: 3000 }]
env:
- name: MCP_PROTOCOL_VERSION
value: "2026-07-28"
readinessProbe:
httpGet: { path: /mcp/discover, port: 3000 }
periodSeconds: 10
livenessProbe:
httpGet: { path: /mcp/discover, port: 3000 }
periodSeconds: 30
resources:
requests: { cpu: "250m", memory: "512Mi" }
limits: { cpu: "1", memory: "1Gi" }
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: mcp-server-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: mcp-server
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource: { name: cpu, target: { type: Utilization, averageUtilization: 70 } }
Important: do not add sessionAffinity: ClientIP on the Service — that reintroduces sticky routing the 2026-07-28 spec makes obsolete. Remove Ingress affinity cookies if you used them before.
The readiness probe on /mcp/discover ensures no instance joins the pool until it announces version 2026-07-28. Pair it with a PodDisruptionBudget (minAvailable: 2) for zero-downtime rolling deploys.
Phase 10 — Multi-round-trip elicitation (SEP-2322)
Servers that requested user confirmation via a persistent SSE connection must migrate to Multi Round-Trip Requests. Server-side handler example:
server.registerTool('delete_files', { /* schema */ }, async (args, extra) => {
if (!args.confirmed) {
return {
resultType: 'inputRequired',
inputRequests: {
confirm: {
type: 'elicitation',
message: `Delete ${args.file_ids.length} files?`,
schema: { type: 'boolean' },
},
},
requestState: Buffer.from(JSON.stringify({ file_ids: args.file_ids })).toString('base64url'),
};
}
await bulkDelete(args.file_ids);
return { content: [{ type: 'text', text: 'Deletion complete' }] };
});
The client receives inputRequired, collects user input, then re-emits the original call with inputResponses and echoed requestState. Any pool instance can handle the retry — context lives entirely in the payload, not in transport session.
Test this flow in MCP Inspector by simulating two consecutive requests with the same requestState. It is the most overlooked migration scenario and the most visible to end users.
Phase 11 — Docker Compose for local test environment
Before touching production, reproduce a minimal local cluster:
# docker-compose.mcp-migration.yml
services:
mcp-1:
build: .
environment: { MCP_PROTOCOL_VERSION: "2026-07-28", INSTANCE_ID: "1" }
mcp-2:
build: .
environment: { MCP_PROTOCOL_VERSION: "2026-07-28", INSTANCE_ID: "2" }
nginx:
image: nginx:1.27-alpine
ports: ["8443:443"]
volumes:
- ./nginx-mcp.conf:/etc/nginx/conf.d/default.conf:ro
depends_on: [mcp-1, mcp-2]
Run the CI test suite against https://localhost:8443/mcp and verify via INSTANCE_ID logs that requests alternate between containers — proof stateless works before cloud deployment.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
404 Unknown session after LB | Sticky removed but server keeps sessions | Remove session store |
400 Header/body mismatch | Client missing Mcp-Name | Upgrade client SDK |
| Tools OK in dev, fail in prod | Gateway rate limit without Mcp-Method | Configure nginx map (§4) |
| Tasks stuck mid-flight | Removed 2025-11-25 Tasks API | Migrate Tasks extension |
| Stale tools/list cache | CDN ignores ttlMs | Honor _meta.cache |
Connection to your existing AI agents
This transport migration does not replace agent design. If you are building a full agent — orchestration, HITL, front-end — our AI agent creation offering now includes MCP 2026-07-28 compatibility audit from scoping. For an operational agent connected to n8n and internal APIs, the deploy an MCP AI agent path remains the starting point; this tutorial is the natural follow-up when you move to multi-instance HTTP.
Conclusion — key takeaways
Migrating to stateless MCP 2026-07-28 is not rewriting your CRM or inventory tools: it is removing transport sessions, adding three HTTP headers, configuring round-robin load balancing, and externalizing state via explicit handles. The operational win is immediate: horizontal scaling without sticky sessions, CDN cache on lists, gateway routing without JSON-RPC parsing.
Final go-live checklist:
- Audit complete, breaking changes checked.
- Client and server SDK ≥ RC-compatible version.
- Inspector + CI tests green on canary.
- Load test 1000 req with zero session errors.
- Rollback runbook tested.
Migrating several MCP servers in parallel? Our team supports transport migrations with OAuth review, load balancer configuration, and CI test suites. Contact us for an MCP stack audit — we start by inventorying your breaking changes, not selling an unnecessary rewrite.
Need an AI agent compatible with 2026-07-28 from the POC? See our AI agent creation offering: protocol audit, handle patterns, OpenTelemetry observability included.
MCP server not built yet? Start from our TypeScript 30-minute tutorial targeting the 2026-07-28 spec directly — you avoid a double migration.
Tags
FAQ
Do I need to migrate my local stdio MCP server to the 2026-07-28 spec?
For a stdio server used only locally (Cursor, Claude Desktop), migration is mainly an SDK upgrade to a 2026-07-28-compatible @modelcontextprotocol/sdk version. Transport changes (Mcp-Session-Id, Mcp-Method headers) primarily affect remote Streamable HTTP. For stdio, focus on removing the initialize handshake, passing metadata in _meta, and validating JSON Schema 2020-12 schemas.
How do I test the migration without cutting production?
Deploy a canary instance on a subdomain (mcp-v2.example.com) behind the same load balancer with a separate pool. Run the MCP Inspector test suite in CI, then route 5–10% of traffic via an X-MCP-Version: 2026-07-28 header. Compare latency, error rate, and OpenTelemetry logs for 48 hours before full cutover.
What if my server depends on an MCP session Redis store?
Remove the protocol-level session store. If your business logic needs continuity (cart, headless browser, multi-step workflow), expose an explicit handle — workflow_id, browser_id — returned by a tool and passed back as an ordinary argument by the model. The protocol becomes stateless; your application can stay stateful via opaque IDs in a database or application Redis, not in MCP sessions.
Which HTTP headers are mandatory after migration?
Every Streamable HTTP POST request must carry MCP-Protocol-Version: 2026-07-28, Mcp-Method (e.g. tools/call), and Mcp-Name (tool or resource name). The server rejects requests where headers and JSON-RPC body diverge (SEP-2243). W3C traceparent propagation in _meta is recommended for observability (SEP-414).
How long does a typical stateless MCP migration take?
For an HTTP server already on Streamable HTTP with 5–15 tools: budget 2 days for audit, 3–5 days for transport and header refactor, 2 days for load balancer config, 3–4 days for CI and canary tests. Total: 10–15 person-days depending on debt (experimental Tasks, legacy HTTP+SSE, custom OAuth). A local stdio server often migrates in half a day via SDK upgrade.
Ready to implement this?
Book a free 30-min strategy call with our experts
We'll analyze your situation and propose a concrete action plan.

William Aklamavo
Web development and automation expert, passionate about technological innovation and digital entrepreneurship.
