DEV Community

Anup Karanjkar
Anup Karanjkar

Posted on • Originally published at wowhow.cloud

MCP Spec Ships July 28 — Every Breaking Change and How to Migrate

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
})
Enter fullscreen mode Exit fullscreen mode

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' },
      })
  }
})
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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)
  },
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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',
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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 : ''
}
Enter fullscreen mode Exit fullscreen mode

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 },
  })
}
Enter fullscreen mode Exit fullscreen mode

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'] },
    ],
  },
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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": , "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
Enter fullscreen mode Exit fullscreen mode

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

  1. MCP 2026-07-28 Release Candidate — Official Blog Post, modelcontextprotocol.io, May 28, 2026

  2. 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)