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<Response> {
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<string, unknown>
) {
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<string, CacheEntry>()
private userCaches = new Map<string, Map<string, CacheEntry>>()
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() < globalEntry.expiresAt) {
return globalEntry.data
}
if (userId) {
const userEntry = this.userCaches.get(userId)?.get(key)
if (userEntry && Date.now() < userEntry.expiresAt) {
return userEntry.data
}
}
return null
}
}
const cache = new McpResponseCache()
async function listTools(userId?: string) {
const cacheKey = 'tools/list'
const cached = cache.get(cacheKey, userId)
if (cached) return cached
const response = await mcpClient.request('tools/list', {
_meta: buildClientMeta(),
})
if (response.result) {
const { tools, ttlMs, cacheScope } = response.result
if (ttlMs && cacheScope && cacheScope !== 'session') {
cache.set(cacheKey, tools, ttlMs, cacheScope, userId)
}
return tools
}
return response.result
}
Change 5: W3C Trace Context in _meta (SEP-414)
The traceparent, tracestate, and baggage keys in _meta are now documented and locked.[1] If you were passing these keys under different names, update them. If you were not propagating trace context at all, this is the moment to add it.
Before: Inconsistent Key Names
// BEFORE — various teams used different key names
const body = {
jsonrpc: '2.0',
id: requestId,
method: 'tools/call',
params: {
name: toolName,
arguments: args,
_meta: {
// Different SDKs used all of these — none were standard
// 'x-trace-id': traceId,
// 'trace-context': traceParent,
// 'otel-traceparent': traceparent,
// 'w3c-traceparent': traceparent,
},
},
}
After: Standard W3C Keys
// AFTER — SEP-414 locks these key names
import { trace, propagation, context } from '@opentelemetry/api'
import { W3CTraceContextPropagator } from '@opentelemetry/core'
const propagator = new W3CTraceContextPropagator()
function buildClientMeta(spanContext?: object) {
const carrier: Record<string, string> = {}
// 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<string, unknown>) {
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<string, unknown> = {}
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/initialized handler and all session store code
- Remove
Mcp-Session-Id header from all responses
- Add
validateMcpHeaders middleware that rejects requests where Mcp-Method is absent or disagrees with the body method
- Add
validateMcpHeaders rejection for Mcp-Name/params.name mismatch on tools/call requests
- Replace all
-32002 error codes with -32602
- Add
ttlMs and cacheScope to tools/list responses
- Add
ttlMs and cacheScope to resources/read responses
- Rename trace context keys in
_meta to traceparent, tracestate, baggage
- Add extraction of W3C trace context from incoming
_meta
- Begin migration away from Roots, Sampling, Logging primitives (deadline: July 2027 earliest removal)
Client Changes
- Stop sending
Mcp-Session-Id in request headers
- Stop sending the
initialize request before first tool call
- Add
Mcp-Method and Mcp-Name headers to every request
- Add
MCP-Protocol-Version: 2026-07-28 header
- Move client metadata (
protocolVersion, capabilities, clientInfo) into _meta on every request body
- Update error code matching from
-32002 to -32602
- Implement cache respecting
ttlMs and cacheScope from list/read responses
- Use standard W3C keys when injecting trace context into
_meta
- Handle
InputRequiredResult response 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-Method
- Update Cloudflare Worker / Nginx / gateway routing rules to read
Mcp-Method
- Validate 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": <number>, "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
- Model Context Protocol Roadmap 2026, The New Stack
Comments · 0
No comments yet. Be the first to share your thoughts.