Disclaimer: Code examples in this article are based on the 2026-07-28 Release Candidate, published May 28, 2026. The final specification may differ. Verify against the official MCP spec before shipping to production.
On May 28, 2026, the MCP team published the largest specification revision since launch — and your MCP servers have ten weeks to comply. The 2026-07-28 Release Candidate eliminates protocol-level sessions, mandates two new HTTP headers, changes an error code that client code almost certainly pattern-matches against, introduces caching semantics via new response fields, locks down distributed trace propagation, and deprecates three first-class primitives. That is six material changes arriving in a single spec bump, with a hard July 28 cutover date.[1] This guide walks through every change in the order you should tackle it, with before-and-after code diffs and a final checklist.
The underlying motivation is operational. 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, route traffic on a header value, and let clients cache the tools list for as long as the server permits. The spec is moving from a stateful, handshake-based architecture toward one that behaves like a well-designed HTTP API. That is a genuinely good direction. The migration cost is real and bounded.
What Broke and Why
Before the diffs, a clear-eyed summary of what the spec actually breaks and the operational problems each removal was meant to fix.
Sessions Existed to Route — Now Headers Do That
The initialize/initialized handshake and the Mcp-Session-Id header were introduced when MCP's Streamable HTTP transport was first designed.[2] A client established a session, received a session ID, and carried that ID in every subsequent request. The server used the ID to look up per-session state — capabilities, negotiated protocol version, client metadata. It worked cleanly for a single-server deployment. It broke the moment you added a second server instance, because the client's next request might land on the instance that had never seen the session.
The common fix was sticky sessions at the load balancer. Some teams added a Redis-backed session store. Others built a gateway that inspected the request body to extract the session ID before routing. All of these solutions exist because the protocol pushed session affinity onto infrastructure. The 2026-07-28 RC removes the session concept entirely from the protocol layer. Client metadata, capabilities, and protocol version now travel in the _meta field on every request. Infrastructure can be stateless because the protocol is now stateless.
The Error Code Was Never Standard
MCP introduced -32002 as a custom error code for missing resources. The JSON-RPC 2.0 specification already has -32602 for Invalid Params. The two codes represent different semantics, but in practice, the custom MCP code created a compatibility problem: any client validating against a JSON-RPC error schema saw -32002 as an unknown code. SEP-2164 collapses this to the standard value.[1] If your client has a switch or if block that pattern-matches on -32002, that branch is now dead code after July 28.
Caching Was Happening Anyway — Now It Has a Contract
Clients caching tools/list responses was always an optimization. The problem was that each client made up its own TTL, and the server had no way to communicate how long the list was valid or whether it was safe to share across users. SEP-2549 adds ttlMs and cacheScope to list and resource read responses — the MCP equivalent of Cache-Control: max-age and Cache-Control: public.[1] This is additive, not a break for server code — but clients that were caching without guidance now have a contract to follow.
Trace Keys Were Colliding
W3C Trace Context propagation through the _meta field was already happening in production — OpenTelemetry-instrumented servers were passing traceparent, tracestate, and baggage through. The problem was that the key names were undocumented, so different SDKs and gateways invented their own conventions. SEP-414 locks down the key names, making distributed traces across multi-SDK deployments actually correlate.[1]
Change 1: Eliminate the Session Handshake
This is the largest structural change. Every piece of server code that participates in session lifecycle needs to be rethought.
What the Old Flow Looked Like
Under the previous spec, a TypeScript server handled initialization roughly like this:
// BEFORE — session-based initialization (remove this pattern entirely)
import express from 'express'
import { randomUUID } from 'crypto'
const sessions = new Map()
app.post('/mcp', async (req, res) => {
const body = req.body
// Handshake: client sends initialize, server stores session
if (body.method === 'initialize') {
const sessionId = randomUUID()
const clientInfo = body.params.clientInfo
const capabilities = body.params.capabilities
sessions.set(sessionId, {
protocolVersion: body.params.protocolVersion,
clientInfo,
capabilities,
createdAt: Date.now(),
})
res.setHeader('Mcp-Session-Id', sessionId)
return res.json({
jsonrpc: '2.0',
id: body.id,
result: {
protocolVersion: '2025-11-05',
capabilities: { tools: {}, resources: {} },
serverInfo: { name: 'my-server', version: '1.0.0' },
},
})
}
// Every subsequent request validates the session
const sessionId = req.headers['mcp-session-id'] as string
if (!sessionId || !sessions.has(sessionId)) {
return res.status(401).json({
jsonrpc: '2.0',
id: body.id,
error: { code: -32600, message: 'Invalid session' },
})
}
const session = sessions.get(sessionId)!
// ... handle tools/call, tools/list, etc. using session state
})
The New Stateless Pattern
Under the RC, the client sends its metadata with every request inside _meta. The server reads what it needs from the request body instead of looking it up in a session store.
// AFTER — stateless, no session store, no handshake
import express from 'express'
app.post('/mcp', async (req, res) => {
const body = req.body
const method = req.headers['mcp-method'] as string
const toolName = req.headers['mcp-name'] as string
// Read client context from _meta on every request
const meta = body.params?._meta ?? {}
const protocolVersion = meta.protocolVersion ?? body.params?.protocolVersion
const clientCapabilities = meta.capabilities ?? {}
// Validate headers match body
if (method && method !== body.method) {
return res.status(400).json({
jsonrpc: '2.0',
id: body.id,
error: { code: -32600, message: 'Mcp-Method header does not match request body' },
})
}
// Route without session lookup
switch (body.method) {
case 'tools/list':
return handleToolsList(req, res, clientCapabilities)
case 'tools/call':
return handleToolsCall(req, res, body.params, toolName)
case 'resources/read':
return handleResourceRead(req, res, body.params)
default:
return res.status(404).json({
jsonrpc: '2.0',
id: body.id,
error: { code: -32601, message: 'Method not found' },
})
}
})
Infrastructure Changes That Follow
With sessions gone from the protocol, your infrastructure can drop the workarounds that existed to compensate for stateful routing:
Load balancer sticky sessions — delete the affinity rule. Any instance can handle any request.
Redis session store — if its only purpose was MCP session state, decommission it. Other uses of Redis (tool result caching, rate limiting) are unaffected.
Gateway body inspection for session IDs — replace with header-based routing on
Mcp-Method. This is cheaper and does not require buffering the entire request body before routing.
If you are running on Kubernetes, the sticky session annotation on your Service or Ingress can be removed. The spec change effectively converts your MCP deployment from a stateful set with affinity requirements to a plain deployment behind a ClusterIP service.
Change 2: New Required Headers (SEP-2243)
Streamable HTTP transport now requires two headers on every request: Mcp-Method and Mcp-Name. The server must reject requests where the headers disagree with the request body.[1]
Client-Side: Adding the Headers
// BEFORE — no method routing headers
async function callTool(serverUrl: string, toolName: string, args: unknown) {
const response = await fetch(serverUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Mcp-Session-Id': currentSessionId, // REMOVE THIS
},
body: JSON.stringify({
jsonrpc: '2.0',
id: crypto.randomUUID(),
method: 'tools/call',
params: { name: toolName, arguments: args },
}),
})
return response.json()
}
// AFTER — Mcp-Method and Mcp-Name required
async function callTool(serverUrl: string, toolName: string, args: unknown) {
const requestId = crypto.randomUUID()
const body = {
jsonrpc: '2.0',
id: requestId,
method: 'tools/call',
params: {
name: toolName,
arguments: args,
_meta: {
protocolVersion: '2026-07-28',
capabilities: clientCapabilities,
clientInfo: { name: 'my-client', version: '2.0.0' },
},
},
}
const response = await fetch(serverUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Mcp-Method': 'tools/call', // REQUIRED — matches body.method
'Mcp-Name': toolName, // REQUIRED — matches body.params.name
'MCP-Protocol-Version': '2026-07-28',
},
body: JSON.stringify(body),
})
return response.json()
}
Server-Side: Validating Header/Body Consistency
The spec requires servers to reject requests where headers and body disagree. This is where a shared validation middleware prevents security issues — a client that sends a permissive Mcp-Method: tools/list header but a tools/call body would otherwise bypass gateway rate limiting that routes on headers.
// Validation middleware — add to every MCP endpoint
function validateMcpHeaders(
req: express.Request,
res: express.Response,
next: express.NextFunction
) {
const mcpMethod = req.headers['mcp-method'] as string | undefined
const mcpName = req.headers['mcp-name'] as string | undefined
const body = req.body
// Headers are required per SEP-2243
if (!mcpMethod) {
return res.status(400).json({
jsonrpc: '2.0',
id: body?.id ?? null,
error: { code: -32600, message: 'Missing required header: Mcp-Method' },
})
}
// Header must match body
if (mcpMethod !== body?.method) {
return res.status(400).json({
jsonrpc: '2.0',
id: body?.id ?? null,
error: {
code: -32600,
message: `Header Mcp-Method '${mcpMethod}' does not match body method '${body?.method}'`,
},
})
}
// For tool calls: Mcp-Name must match the tool name in the body
if (mcpMethod === 'tools/call' && mcpName !== body?.params?.name) {
return res.status(400).json({
jsonrpc: '2.0',
id: body?.id ?? null,
error: {
code: -32600,
message: `Header Mcp-Name '${mcpName}' does not match body params.name '${body?.params?.name}'`,
},
})
}
next()
}
// Apply before routing
app.post('/mcp', validateMcpHeaders, mcpRouter)
Gateway and Load Balancer Configuration
The headers exist precisely so that routing infrastructure does not need to inspect the body. A Cloudflare Worker or Nginx configuration can now route traffic on a single header value rather than parsing JSON:
# Nginx upstream routing on Mcp-Method (no body inspection needed)
map $http_mcp_method $backend_pool {
"tools/call" tools_pool;
"tools/list" metadata_pool;
"resources/read" resources_pool;
default default_pool;
}
server {
location /mcp {
proxy_pass http://$backend_pool;
proxy_set_header Mcp-Method $http_mcp_method;
proxy_set_header Mcp-Name $http_mcp_name;
}
}
// Cloudflare Worker — route on Mcp-Method without parsing body
export default {
async fetch(request: Request): Promise {
const mcpMethod = request.headers.get('Mcp-Method')
if (mcpMethod === 'tools/call') {
// Route to compute-heavy pool
return fetch('https://tools-compute.internal/mcp', request)
}
if (mcpMethod === 'tools/list' || mcpMethod === 'resources/list') {
// Route to metadata pool — lighter, cached
return fetch('https://metadata.internal/mcp', request)
}
return fetch('https://default.internal/mcp', request)
},
}
Change 3: Error Code Migration (SEP-2164)
The error code for a missing resource changes from -32002 to -32602. This is a small change with outsized blast radius because error codes tend to be pattern-matched against in switch statements and condition checks scattered across client codebases.[1]
Finding Affected Code
Before touching anything, find every occurrence of the old code across your codebase. The number of matches will tell you how much work is ahead:
# Search for the literal value — catches both numeric and string forms
grep -r "-32002" ./src --include="*.ts" --include="*.js" -l
# Also check for named constants that might wrap it
grep -r "RESOURCE_NOT_FOUND|MISSING_RESOURCE|MCP_NOT_FOUND" ./src -l
The Migration Diff
// BEFORE — custom MCP error code for missing resource
async function handleResourceRead(
req: express.Request,
res: express.Response,
params: { uri: string }
) {
const resource = await resourceStore.get(params.uri)
if (!resource) {
return res.json({
jsonrpc: '2.0',
id: req.body.id,
error: {
code: -32002, // ← CHANGE THIS
message: `Resource not found: ${params.uri}`,
},
})
}
return res.json({ jsonrpc: '2.0', id: req.body.id, result: resource })
}
// AFTER — JSON-RPC standard Invalid Params (-32602)
async function handleResourceRead(
req: express.Request,
res: express.Response,
params: { uri: string }
) {
const resource = await resourceStore.get(params.uri)
if (!resource) {
return res.json({
jsonrpc: '2.0',
id: req.body.id,
error: {
code: -32602, // ← SEP-2164: standard JSON-RPC Invalid Params
message: `Resource not found: ${params.uri}`,
data: { uri: params.uri },
},
})
}
return res.json({ jsonrpc: '2.0', id: req.body.id, result: resource })
}
Client-Side Error Handling
// BEFORE — matching on MCP custom code
async function readResource(uri: string) {
const response = await mcpClient.request('resources/read', { uri })
if (response.error) {
if (response.error.code === -32002) { // ← UPDATE THIS
throw new ResourceNotFoundError(uri)
}
throw new McpError(response.error)
}
return response.result
}
// AFTER — matching on standard JSON-RPC code
async function readResource(uri: string) {
const response = await mcpClient.request('resources/read', { uri })
if (response.error) {
if (response.error.code === -32602) { // ← SEP-2164
throw new ResourceNotFoundError(uri)
}
throw new McpError(response.error)
}
return response.result
}
// Error code constants file — update the mapping
export const MCP_ERROR_CODES = {
PARSE_ERROR: -32700,
INVALID_REQUEST: -32600,
METHOD_NOT_FOUND: -32601,
INVALID_PARAMS: -32602, // replaces old -32002 for missing resources
INTERNAL_ERROR: -32603,
} as const
One important nuance: -32602 (Invalid Params) is a broader category than the old -32002. After this migration, your client code that catches -32602 will also catch other invalid-parameter errors. If you need to distinguish between "missing resource" and "malformed parameters," you should use the error data field rather than the code:
// Distinguishing resource-not-found from other -32602 errors via error.data
if (response.error?.code === -32602) {
if (response.error.data?.uri) {
// This is a resource-not-found case
throw new ResourceNotFoundError(response.error.data.uri)
}
// Other invalid params error
throw new InvalidParamsError(response.error.message)
}
Change 4: Caching Metadata (SEP-2549)
List and resource read responses now carry two new fields: ttlMs and cacheScope. Servers that do not add these fields are still spec-compliant — the fields are optional. But clients that were previously caching without guidance now have a standard contract to follow.[1]
Server-Side: Adding Cache Metadata to Responses
// BEFORE — tools/list response without caching guidance
async function handleToolsList(
req: express.Request,
res: express.Response
) {
const tools = await toolRegistry.list()
return res.json({
jsonrpc: '2.0',
id: req.body.id,
result: {
tools,
},
})
}
// AFTER — tools/list with caching metadata (SEP-2549)
async function handleToolsList(
req: express.Request,
res: express.Response,
clientCapabilities: Record
) {
const tools = await toolRegistry.list()
const userId = extractUserId(req) // null for unauthenticated requests
return res.json({
jsonrpc: '2.0',
id: req.body.id,
result: {
tools,
// ttlMs: how long the response is valid
// cacheScope: 'global' = safe to share across users
// 'user' = specific to this user
// 'session' = not safe to cache across requests
ttlMs: 300_000, // 5 minutes — tools list rarely changes
cacheScope: userId // user-scoped if authenticated
? 'user'
: 'global',
},
})
}
// Resource read — typically shorter TTL and user-scoped
async function handleResourceRead(
req: express.Request,
res: express.Response,
params: { uri: string }
) {
const resource = await resourceStore.get(params.uri)
if (!resource) {
return res.json({
jsonrpc: '2.0',
id: req.body.id,
error: { code: -32602, message: `Resource not found: ${params.uri}` },
})
}
return res.json({
jsonrpc: '2.0',
id: req.body.id,
result: {
contents: resource.contents,
ttlMs: resource.isStatic ? 3_600_000 : 30_000, // 1h static, 30s dynamic
cacheScope: resource.isPublic ? 'global' : 'user',
},
})
}
Client-Side: Respecting Cache Metadata
// Client-side cache respecting ttlMs and cacheScope
interface CacheEntry {
data: unknown
expiresAt: number
scope: 'global' | 'user' | 'session'
}
class McpResponseCache {
private globalCache = new Map()
private userCaches = new Map>()
set(key: string, data: unknown, ttlMs: number, scope: string, userId?: string) {
const entry: CacheEntry = {
data,
expiresAt: Date.now() + ttlMs,
scope: scope as CacheEntry['scope'],
}
if (scope === 'global') {
this.globalCache.set(key, entry)
} else if (scope === 'user' && userId) {
if (!this.userCaches.has(userId)) {
this.userCaches.set(userId, new Map())
}
this.userCaches.get(userId)!.set(key, entry)
}
// scope === 'session' → do not cache
}
get(key: string, userId?: string): unknown | null {
const globalEntry = this.globalCache.get(key)
if (globalEntry && Date.now() = {}
// Inject current trace context into carrier
propagation.inject(context.active(), carrier)
return {
protocolVersion: '2026-07-28',
capabilities: clientCapabilities,
clientInfo: { name: 'my-client', version: '2.0.0' },
// SEP-414: standard W3C Trace Context key names
traceparent: carrier['traceparent'], // e.g. "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
tracestate: carrier['tracestate'], // vendor-specific trace data
baggage: carrier['baggage'], // arbitrary key-value pairs
}
}
// Server-side: extract and continue the trace
function extractTraceContext(meta: Record) {
const carrier = {
traceparent: meta.traceparent as string | undefined,
tracestate: meta.tracestate as string | undefined,
baggage: meta.baggage as string | undefined,
}
return propagation.extract(context.active(), carrier)
}
// In your handler
async function handleToolsCall(req: express.Request, res: express.Response, params: unknown) {
const meta = req.body.params?._meta ?? {}
const traceCtx = extractTraceContext(meta)
// All spans created within traceCtx are children of the incoming trace
return context.with(traceCtx, async () => {
const span = trace.getTracer('mcp-server').startSpan('tools/call')
try {
const result = await executeToolCall(params)
return res.json({ jsonrpc: '2.0', id: req.body.id, result })
} finally {
span.end()
}
})
}
Change 6: Three Primitives Deprecated
The RC deprecates Roots, Sampling, and Logging — three first-class primitives in the previous spec. Deprecated does not mean removed on July 28. The governance lifecycle introduced in the same RC mandates at least 12 months between deprecation and earliest removal.[1] But the migration path is clear and you should start it now.
Roots → Tool Parameters, Resource URIs, or Server Config
Roots were a mechanism for a client to tell a server which filesystem paths it had access to. The intended replacement depends on what you were using them for:
If roots were used to pass a working directory to tools — pass it as a tool parameter instead. The tool schema makes it explicit.
If roots were used to scope resource access — encode the scope in the resource URI and validate it server-side.
If roots were used for server configuration — move them to server startup configuration or environment variables.
// BEFORE — using Roots to pass working directory
const initResult = await mcpClient.initialize({
roots: [
{ uri: 'file:///workspace/project', name: 'Project Root' }
]
})
// AFTER — pass as tool parameter
const result = await mcpClient.callTool('read_file', {
path: '/workspace/project/src/index.ts', // explicit path in args
})
Sampling → Direct LLM API Integration
Sampling allowed an MCP server to request that the client perform an LLM completion on the server's behalf. This created an unusual inversion of the typical client-server relationship and complicated the security model. The replacement is for the server to call the LLM API directly.
// BEFORE — MCP server requesting sampling from client
// Server code that sends a sampling request
async function analyzeData(data: string) {
const samplingResult = await sendSamplingRequest({
messages: [
{ role: 'user', content: { type: 'text', text: `Analyze: ${data}` } }
],
maxTokens: 1000,
})
return samplingResult.content
}
// AFTER — server calls LLM directly
import Anthropic from '@anthropic-ai/sdk'
const anthropic = new Anthropic()
async function analyzeData(data: string) {
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 1000,
messages: [
{ role: 'user', content: `Analyze: ${data}` }
],
})
return response.content[0].type === 'text' ? response.content[0].text : ''
}
Logging → stderr or OpenTelemetry
MCP's built-in Logging primitive sent log messages from the server to the client. The replacements are simpler and more standard: write to stderr for stdio transports, use OpenTelemetry structured logging for everything else.
// BEFORE — using MCP Logging primitive
await mcpServer.sendLog({
level: 'info',
logger: 'my-server',
data: { message: 'Tool execution started', toolName },
})
// AFTER — stderr for stdio, OpenTelemetry for HTTP
import { logs, SeverityNumber } from '@opentelemetry/api-logs'
const logger = logs.getLogger('mcp-server')
// For stdio transport: write structured JSON to stderr
if (transport === 'stdio') {
process.stderr.write(JSON.stringify({
level: 'info',
message: 'Tool execution started',
toolName,
timestamp: new Date().toISOString(),
}) + '\n')
} else {
// For HTTP transport: use OpenTelemetry logs API
logger.emit({
severityNumber: SeverityNumber.INFO,
body: 'Tool execution started',
attributes: { toolName },
})
}
Change 7: JSON Schema 2020-12 for Tool Input Schemas
Tool input schemas now support JSON Schema 2020-12 composition and conditionals, and the structuredContent field on tool call results now accepts any JSON value, not just objects.[1] This is largely additive — existing schemas remain valid — but two rules matter for migration.
First, input schemas must still have type: "object" at the root. You can add composition operators (oneOf, anyOf, allOf) and conditionals, but the root type constraint stays. Second, do not auto-dereference external $ref URIs — the spec explicitly prohibits servers from fetching and inlining remote schemas.
// BEFORE — simple flat input schema
const searchTool = {
name: 'search_products',
description: 'Search the product catalog',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string' },
limit: { type: 'number' },
},
required: ['query'],
},
}
// AFTER — JSON Schema 2020-12 with composition and conditionals
const searchTool = {
name: 'search_products',
description: 'Search the product catalog with optional filtering',
inputSchema: {
type: 'object', // root type: object still required
properties: {
query: { type: 'string', minLength: 1 },
limit: { type: 'number', minimum: 1, maximum: 100, default: 10 },
format: { enum: ['json', 'csv', 'markdown'] },
filters: {
type: 'object',
properties: {
priceMin: { type: 'number' },
priceMax: { type: 'number' },
category: { type: 'string' },
},
},
},
required: ['query'],
// Conditional: if format is csv, filters are not allowed
if: { properties: { format: { const: 'csv' } }, required: ['format'] },
then: { not: { required: ['filters'] } },
// anyOf composition: either a text query or a structured filter
anyOf: [
{ required: ['query'] },
{ required: ['filters'] },
],
},
}
Multi-Round-Trip Tool Calls: InputRequiredResult
The RC introduces a new result type for tool calls that need additional input from the client: InputRequiredResult. This covers scenarios like OAuth confirmation, human-in-the-loop approval, and tools that need clarification before executing. The entire pattern is designed to be stateless — the server encodes its state as a base64 blob that the client echoes back on the follow-up request.[1]
// Server: returning InputRequiredResult when confirmation is needed
async function handleToolsCall(req: express.Request, res: express.Response, params: ToolCallParams) {
const { name, arguments: args, inputResponses, requestState } = params
if (name === 'delete_records' && !inputResponses) {
// First call — ask for confirmation
const pendingState = Buffer.from(JSON.stringify({
operation: 'delete_records',
args,
requestedAt: Date.now(),
})).toString('base64')
return res.json({
jsonrpc: '2.0',
id: req.body.id,
result: {
resultType: 'inputRequired',
inputRequests: {
confirmation: {
type: 'boolean',
prompt: `Delete ${args.count} records? This cannot be undone.`,
},
},
requestState: pendingState,
},
})
}
if (name === 'delete_records' && inputResponses && requestState) {
// Follow-up call with user's response
if (!inputResponses.confirmation) {
return res.json({
jsonrpc: '2.0',
id: req.body.id,
result: { content: [{ type: 'text', text: 'Operation cancelled.' }] },
})
}
// Decode state and execute — any server instance can handle this
const pendingOp = JSON.parse(Buffer.from(requestState, 'base64').toString())
const deleted = await db.deleteRecords(pendingOp.args)
return res.json({
jsonrpc: '2.0',
id: req.body.id,
result: { content: [{ type: 'text', text: `Deleted ${deleted} records.` }] },
})
}
}
// Client: handling InputRequiredResult
async function callToolWithConfirmation(toolName: string, args: unknown) {
const firstResponse = await mcpClient.callTool(toolName, args)
if (firstResponse.result?.resultType === 'inputRequired') {
const { inputRequests, requestState } = firstResponse.result
// Collect responses from user or upstream system
const inputResponses: Record = {}
for (const [key, request] of Object.entries(inputRequests)) {
inputResponses[key] = await promptUser(request)
}
// Re-issue with responses and echoed requestState
return mcpClient.callTool(toolName, {
...args,
inputResponses,
requestState, // echo back unchanged
})
}
return firstResponse
}
Governance: What the Lifecycle Policy Means for You
The three SEPs that formalize governance matter for how you track future changes, not just this migration.[2]
SEP-2577 introduces a three-tier lifecycle: Active, Deprecated, Removed. Every feature has a stated status. The policy mandates at least 12 months between a deprecation announcement and the earliest possible removal. For the three primitives deprecated in the RC — Roots, Sampling, Logging — the earliest removal date is therefore July 2027. You have time, but the clock is running.
SEP-2133 introduces the extension framework with reverse-DNS identifiers. Extensions are opt-in capabilities that client and server negotiate via an extensions map in their capabilities exchange. New capabilities ship as extensions before being promoted to core spec. If you are evaluating a vendor's MCP SDK and they mention capabilities that are not in the current spec, check whether those are published extensions under SEP-2133 or proprietary additions.
The practical implication of the governance changes: watch the MCP repository for SEPs that enter Deprecated status. A deprecation notice is now a 12-month countdown, not an indefinite soft warning.
Migration Checklist
Work through this in order. Each section should be verifiable before you move to the next.
Server Changes
Remove the
initialize/initializedhandler and all session store codeRemove
Mcp-Session-Idheader from all responsesAdd
validateMcpHeadersmiddleware that rejects requests whereMcp-Methodis absent or disagrees with the body methodAdd
validateMcpHeadersrejection forMcp-Name/params.namemismatch ontools/callrequestsReplace all
-32002error codes with-32602Add
ttlMsandcacheScopetotools/listresponsesAdd
ttlMsandcacheScopetoresources/readresponsesRename trace context keys in
_metatotraceparent,tracestate,baggageAdd extraction of W3C trace context from incoming
_metaBegin migration away from Roots, Sampling, Logging primitives (deadline: July 2027 earliest removal)
Client Changes
Stop sending
Mcp-Session-Idin request headersStop sending the
initializerequest before first tool callAdd
Mcp-MethodandMcp-Nameheaders to every requestAdd
MCP-Protocol-Version: 2026-07-28headerMove client metadata (
protocolVersion,capabilities,clientInfo) into_metaon every request bodyUpdate error code matching from
-32002to-32602Implement cache respecting
ttlMsandcacheScopefrom list/read responsesUse standard W3C keys when injecting trace context into
_metaHandle
InputRequiredResultresponse type on tool calls
Infrastructure Changes
Remove sticky session affinity from load balancer configuration
Decommission shared session store (Redis or otherwise) if its only use was MCP sessions
Replace body-inspection-based routing with header-based routing on
Mcp-MethodUpdate Cloudflare Worker / Nginx / gateway routing rules to read
Mcp-MethodValidate that horizontal scaling works — deploy two instances and verify requests route to both
Verification Gates
Before marking any of the above complete, run these checks:
# Gate 1: Server rejects requests missing Mcp-Method
curl -s -X POST https://your-mcp-server/mcp -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":"1","method":"tools/list","params":{}}' | jq '.error.code'
# Expected: -32600
# Gate 2: Server rejects header/body mismatch
curl -s -X POST https://your-mcp-server/mcp -H "Content-Type: application/json" -H "Mcp-Method: tools/list" -d '{"jsonrpc":"2.0","id":"2","method":"tools/call","params":{"name":"x","arguments":{}}}' | jq '.error.message'
# Expected: error about header/body mismatch
# Gate 3: tools/list response includes caching fields
curl -s -X POST https://your-mcp-server/mcp -H "Content-Type: application/json" -H "Mcp-Method: tools/list" -H "MCP-Protocol-Version: 2026-07-28" -d '{"jsonrpc":"2.0","id":"3","method":"tools/list","params":{"_meta":{"protocolVersion":"2026-07-28"}}}' | jq '{ttlMs: .result.ttlMs, cacheScope: .result.cacheScope}'
# Expected: {"ttlMs": , "cacheScope": "global"|"user"|"session"}
# Gate 4: Error code is now -32602 for missing resource
curl -s -X POST https://your-mcp-server/mcp -H "Content-Type: application/json" -H "Mcp-Method: resources/read" -H "MCP-Protocol-Version: 2026-07-28" -d '{"jsonrpc":"2.0","id":"4","method":"resources/read","params":{"uri":"nonexistent://x","_meta":{}}}' | jq '.error.code'
# Expected: -32602
# Gate 5: Two server instances can handle requests interchangeably
# Send 10 requests and verify all return 200 when routed round-robin
for i in {1..10}; do
curl -s -o /dev/null -w "%{http_code}" -X POST https://your-mcp-server/mcp -H "Content-Type: application/json" -H "Mcp-Method: tools/list" -H "MCP-Protocol-Version: 2026-07-28" -d '{"jsonrpc":"2.0","id":"'$i'","method":"tools/list","params":{"_meta":{"protocolVersion":"2026-07-28"}}}'
echo ""
done
# Expected: all 200
Timeline and SDK Support
The Release Candidate was locked on May 21, 2026. The final specification publishes July 28. Tier 1 SDKs — the official TypeScript and Python SDKs maintained by the MCP team — are expected to ship 2026-07-28 support within the 10-week window between RC lock and final spec.[1]
If you are using an official SDK, the migration path is largely handled at the SDK layer. You will need to update the SDK version, move session initialization code out of your app startup, and add the new headers. The structural changes to your server code depend on how much session state management was living in application code versus the SDK.
If you rolled a custom MCP implementation — and many production deployments have, given how early the ecosystem is — this guide is your migration spec. Every change documented above is a direct consequence of what the spec text changed, drawn from the RC blog post and the SEP references it cites.
The Tasks API migration is a separate track. If you are using the experimental 2025-11-25 Tasks API, that moves to the extension lifecycle under SEP-2133. Watch the repository for the extension identifier and update your capability negotiation accordingly.
What This Migration Actually Buys
The changes are not cosmetic. Each one has a concrete operational payoff.
Stateless protocol means horizontal scaling without infrastructure tricks. A deployment that previously required a Redis cluster to share session state, a sticky load balancer, and a gateway that could parse JSON to extract session IDs can now run as a plain deployment behind a dumb round-robin load balancer. The operational surface shrinks.
Header-based routing means cheaper gateways. L7 routing on a header field is an order of magnitude cheaper than buffering a full request body to parse JSON. Rate limiters, gateways, and Cloudflare Workers that handle MCP traffic get simpler and faster.
Standardized error codes mean fewer custom error handling paths. Every client library that already understands JSON-RPC 2.0 error semantics will handle -32602 without bespoke logic. The custom code was a compatibility tax.
Cache metadata means the tools list gets cached correctly instead of by convention. Some clients were caching for 60 seconds because that felt right. Others were not caching at all. The ttlMs and cacheScope fields give the server — which actually knows how often the tools list changes — authority over the cache policy.
Locked trace keys mean distributed traces actually correlate. Before SEP-414, an OpenTelemetry trace that crossed an MCP boundary would silently break because the receiving SDK was looking for a different key name. That class of debugging frustration ends with the RC.
The 10-week window between RC lock and final spec is deliberate. It is enough time to complete the migration on a realistic schedule without being so long that teams defer starting. The Tier 1 SDKs shipping within the same window means the ecosystem has working reference implementations before the final spec drops.
Start with the session removal. It is the largest structural change and determines what else needs to change downstream. The headers, error code, and cache metadata follow naturally from it. The trace context work is largely additive. The deprecated primitives have a 12-month runway. That ordering gives you a migration that makes steady progress rather than one that stalls on the hardest problem first.
Related Tools
For testing your migrated MCP server locally, the JSON formatter and validator is useful for inspecting request/response payloads. The regex tester helps with writing header validation patterns. For the codebase search to find all -32002 occurrences, the developer tools collection includes a code diffing utility.
For broader context on running MCP servers in production — authentication, gateway patterns, rate limiting — the MCP production hardening guide covers the patterns that matter before and after this spec update. The MCP developer guide covers the protocol fundamentals if you are starting from first principles.
This is authored by Anup Karanjkar, who has been building and operating MCP-integrated systems since the protocol's first public release.
Footnotes
MCP 2026-07-28 Release Candidate — Official Blog Post, modelcontextprotocol.io, May 28, 2026
MCP Development Roadmap, modelcontextprotocol.io, last updated March 5, 2026
3. Model Context Protocol Roadmap 2026, The New Stack
Originally published at wowhow.cloud
Top comments (0)