Skip to main content
Tutorials18 min read

Tutorial: Migrate Your MCP Server to the 2026-07-28 Stateless Spec

Practical guide to migrate a production MCP server to the 2026-07-28 spec: remove Mcp-Session-Id, stateless Streamable HTTP, load balancer setup, breaking changes checklist, and test procedures.

Tutorial: Migrate Your MCP Server to the 2026-07-28 Stateless Spec

Tutorial: Migrate Your MCP Server to the 2026-07-28 Stateless Spec

The MCP 2026-07-28 spec does not ask you to rewrite your tools — it asks you to rewrite the transport layer. This tutorial walks you through audit to production with TypeScript and Python code.

On July 28, 2026, the Model Context Protocol ships its stateless revision: end of the initialize handshake, removal of the Mcp-Session-Id header, and mandatory Mcp-Method and Mcp-Name headers on Streamable HTTP. If you read our analysis of the 2026 stateless MCP spec for enterprise agents, you know the why. This article covers the how — with testable code snippets, load balancer configuration, a breaking changes checklist, and reproducible test procedures.

This tutorial targets teams that already run an MCP server in production — often built with our create an MCP server in TypeScript in 30 minutes or deploy an MCP AI agent in 20 minutes guides. You do not start from scratch: you migrate transport conventions, not business logic.


What actually changes for your server

Before opening your editor, align on vocabulary. Under 2025-11-25, a remote MCP client established a session, received an Mcp-Session-Id, and every follow-up request had to hit the same server instance. Under 2026-07-28, each request is self-contained: protocol version, client identity, and capabilities travel in _meta; the load balancer can route round-robin.

ElementBefore (2025-11-25)After (2026-07-28)
Handshakeinitialize + notifications/initializedRemoved (SEP-2575)
SessionMcp-Session-Id headerRemoved (SEP-2567)
LB routingSticky sessions requiredRound-robin possible
HTTP headersOptionalMcp-Method, Mcp-Name required (SEP-2243)
DiscoveryVia initializeserver/discover RPC
TasksExperimental coreExtension with new lifecycle
JSON SchemaLimited draftFull JSON Schema 2020-12 (SEP-2106)

Stateless MCP migration flow in 8 stepsFrom initial audit to production deployment: audit, breaking changes, SDK, transport, load balancer, tests, canary, cutover


Phase 1 — Audit your existing MCP stack

Start with a factual inventory. Without it, you will discover a breaking change in production on a Friday evening.

Audit checklist (30 minutes)

Run these commands on your repo and infrastructure:

# Find MCP session dependencies
rg -n "Mcp-Session-Id|sessionId|StreamableHTTPServerTransport" --type ts --type py

# Legacy initialize handshake
rg -n "initialize|notifications/initialized" src/

# Deprecated HTTP+SSE transport
rg -n "SSEServerTransport|sse" src/

# Experimental 2025-11-25 Tasks API
rg -n "tasks/list|experimental.*tasks" src/

# Legacy error code -32002
rg -n "\-32002" src/

For each MCP server, note:

  1. Transport: stdio, Streamable HTTP, or legacy HTTP+SSE (migrate first).
  2. Session dependencies: Redis, sticky cookie, Kubernetes affinity.
  3. Connected clients: Cursor, Claude Desktop, n8n, custom SDK.
  4. SDK version: @modelcontextprotocol/sdk ≥ 2026-07-28 RC compatible.

Mind map of MCP 2026-07-28 migrationFour axes: breaking changes, stateless transport, infrastructure, validation

Quick decision matrix

Stateless MCP migration decision treeRemote HTTP server, MCP session, experimental Tasks: three questions to prioritize actions

  • Local stdio only → SDK upgrade + schema validation; no load balancer.
  • Streamable HTTP with sessions → full transport refactor + infra.
  • Legacy HTTP+SSE → migrate to Streamable HTTP before or during stateless cutover.

Phase 2 — TypeScript migration: from 2025-11-25 to stateless

We start from a server like the one in our TypeScript tutorial, adapted for HTTP with sessions. Here is a simplified legacy version:

// ❌ BEFORE — 2025-11-25 session pattern (illustrative excerpt)
import express from 'express';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';

const app = express();
app.use(express.json());

const sessions = new Map<string, StreamableHTTPServerTransport>();

app.post('/mcp', async (req, res) => {
  const sessionId = req.headers['mcp-session-id'] as string | undefined;

  if (!sessionId) {
    const transport = new StreamableHTTPServerTransport('/mcp', res);
    const server = createMcpServer();
    await server.connect(transport);
    sessions.set(transport.sessionId!, transport);
    return;
  }

  const transport = sessions.get(sessionId);
  if (!transport) {
    res.status(404).json({ error: 'Unknown session' });
    return;
  }
  await transport.handleRequest(req, res);
});

2026-07-28 stateless version

// ✅ AFTER — stateless Streamable HTTP (2026-07-28)
import express, { Request, Response } from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';

const PROTOCOL_VERSION = '2026-07-28';

function createMcpServer(): McpServer {
  const server = new McpServer(
    { name: 'crm-connector', version: '2.0.0' },
    { capabilities: { tools: {} } }
  );

  server.registerTool(
    'search_contacts',
    {
      title: 'Search CRM contacts',
      description: 'Full-text search in enterprise CRM',
      inputSchema: {
        type: 'object',
        properties: {
          query: { type: 'string', minLength: 1 },
          limit: { type: 'integer', minimum: 1, maximum: 50, default: 10 },
        },
        required: ['query'],
      },
    },
    async ({ query, limit = 10 }) => {
      const results = await crmSearch(query, limit);
      return {
        content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
        structuredContent: results,
      };
    }
  );

  // Explicit handle pattern for business state
  server.registerTool(
    'init_export_session',
    {
      title: 'Initialize export session',
      description: 'Creates an export_id for subsequent export_chunk calls',
      inputSchema: { type: 'object', properties: {} },
    },
    async () => {
      const exportId = await createExportSession();
      return {
        content: [{ type: 'text', text: `export_id: ${exportId}` }],
        structuredContent: { export_id: exportId },
      };
    }
  );

  return server;
}

const app = express();
app.use(express.json());

// Mandatory discover endpoint (SEP-2575)
app.get('/mcp/discover', (_req: Request, res: Response) => {
  res.json({
    protocolVersions: [PROTOCOL_VERSION, '2025-11-25'],
    serverInfo: { name: 'crm-connector', version: '2.0.0' },
    capabilities: { tools: {} },
  });
});

app.post('/mcp', async (req: Request, res: Response) => {
  const protocolVersion = req.headers['mcp-protocol-version'] as string;
  const mcpMethod = req.headers['mcp-method'] as string;
  const mcpName = req.headers['mcp-name'] as string | undefined;

  if (protocolVersion !== PROTOCOL_VERSION) {
    res.status(400).json({
      jsonrpc: '2.0',
      error: { code: -32602, message: 'UnsupportedProtocolVersionError' },
      id: null,
    });
    return;
  }

  // SEP-2243: header/body consistency
  const bodyMethod = req.body?.method as string | undefined;
  if (mcpMethod && bodyMethod && mcpMethod !== bodyMethod) {
    res.status(400).json({
      jsonrpc: '2.0',
      error: { code: -32602, message: 'Header/body method mismatch' },
      id: req.body?.id ?? null,
    });
    return;
  }

  if (bodyMethod === 'tools/call') {
    const toolName = req.body?.params?.name as string;
    if (mcpName && toolName && mcpName !== toolName) {
      res.status(400).json({
        jsonrpc: '2.0',
        error: { code: -32602, message: 'Header/body tool name mismatch' },
        id: req.body?.id ?? null,
      });
      return;
    }
  }

  if (!req.body?.params?._meta) {
    req.body.params = {
      ...req.body.params,
      _meta: {
        'io.modelcontextprotocol/protocolVersion': PROTOCOL_VERSION,
        'io.modelcontextprotocol/clientInfo': {
          name: req.headers['user-agent'] ?? 'unknown-client',
          version: '1.0.0',
        },
      },
    };
  }

  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined, // stateless: no session
  });

  const server = createMcpServer();
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

app.listen(3000, () => console.log('Stateless MCP on :3000/mcp'));

Key diff points:

  1. Remove the session Map and all Mcp-Session-Id logic.
  2. Validate Mcp-Method / Mcp-Name headers vs JSON-RPC body.
  3. Add GET /mcp/discover for compatibility probing.
  4. Business state via explicit export_id, not transport session.

Updating to JSON Schema 2020-12

SEP-2106 allows oneOf, anyOf, and $ref/$defs. Example for a polymorphic tool:

server.registerTool(
  'update_entity',
  {
    inputSchema: {
      type: 'object',
      oneOf: [
        {
          type: 'object',
          properties: {
            entity_type: { const: 'contact' },
            contact_id: { type: 'string' },
            email: { type: 'string', format: 'email' },
          },
          required: ['entity_type', 'contact_id', 'email'],
        },
        {
          type: 'object',
          properties: {
            entity_type: { const: 'deal' },
            deal_id: { type: 'string' },
            stage: { type: 'string', enum: ['lead', 'qualified', 'won'] },
          },
          required: ['entity_type', 'deal_id', 'stage'],
        },
      ],
    },
  },
  async (args) => {
    return { content: [{ type: 'text', text: 'OK' }] };
  }
);

Add a CI step validating schemas against JSON Schema 2020-12 with ajv v8+.


Phase 3 — Python migration: FastMCP to stateless

If your stack is Python (often FastMCP or the official SDK), the logic is identical. Here is a stateless FastAPI server:

# server_stateless.py — MCP 2026-07-28 (illustrative excerpt)
from fastapi import FastAPI, Request, Response, HTTPException
from mcp.server.fastmcp import FastMCP

PROTOCOL_VERSION = "2026-07-28"
mcp = FastMCP("inventory-connector", json_response=True)

@mcp.tool()
async def check_stock(sku: str, warehouse: str = "main") -> dict:
    """Check stock quantity for a SKU in a warehouse."""
    qty = await db_get_stock(sku, warehouse)
    return {"sku": sku, "warehouse": warehouse, "quantity": qty}

@mcp.tool()
async def reserve_stock(sku: str, quantity: int, reservation_id: str | None = None) -> dict:
    """Reserve stock. Pass reservation_id on follow-up calls."""
    if reservation_id is None:
        reservation_id = await create_reservation(sku, quantity)
    else:
        await confirm_reservation(reservation_id)
    return {"reservation_id": reservation_id, "status": "held"}

app = FastAPI()

@app.get("/mcp/discover")
async def discover():
    return {
        "protocolVersions": [PROTOCOL_VERSION],
        "serverInfo": {"name": "inventory-connector", "version": "2.0.0"},
        "capabilities": {"tools": {}},
    }

@app.post("/mcp")
async def handle_mcp(request: Request):
    protocol_version = request.headers.get("mcp-protocol-version")
    mcp_method = request.headers.get("mcp-method")
    mcp_name = request.headers.get("mcp-name")

    if protocol_version != PROTOCOL_VERSION:
        raise HTTPException(status_code=400, detail="UnsupportedProtocolVersionError")

    body = await request.json()
    if mcp_method and body.get("method") and mcp_method != body["method"]:
        raise HTTPException(status_code=400, detail="Header/body method mismatch")

    if body.get("method") == "tools/call":
        tool_name = body.get("params", {}).get("name")
        if mcp_name and tool_name and mcp_name != tool_name:
            raise HTTPException(status_code=400, detail="Header/body tool name mismatch")

    return await mcp.handle_stateless_request(request, body)

Install a compatible SDK version:

pip install "mcp>=1.10.0"  # verify exact version in official docs

Phase 4 — Load balancer configuration (nginx)

One major stateless win: no protocol-mandated sticky sessions. Production-ready nginx config:

# /etc/nginx/conf.d/mcp-upstream.conf
upstream mcp_backend {
    least_conn;
    server mcp-1.internal:3000 max_fails=3 fail_timeout=30s;
    server mcp-2.internal:3000 max_fails=3 fail_timeout=30s;
    server mcp-3.internal:3000 max_fails=3 fail_timeout=30s;
    keepalive 32;
}

map $http_mcp_method $mcp_rate_zone {
    default         "mcp_default";
    "tools/call"    "mcp_tools";
    "tools/list"    "mcp_list";
}

limit_req_zone $binary_remote_addr zone=mcp_default:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=mcp_tools:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=mcp_list:10m rate=60r/s;

server {
    listen 443 ssl http2;
    server_name mcp.example.com;

    ssl_certificate     /etc/ssl/mcp/fullchain.pem;
    ssl_certificate_key /etc/ssl/mcp/privkey.pem;

    location /mcp/discover {
        proxy_pass http://mcp_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_cache mcp_discover_cache;
        proxy_cache_valid 200 60s;
    }

    location /mcp {
        limit_req zone=$mcp_rate_zone burst=20 nodelay;

        proxy_pass http://mcp_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # NO sticky session — that is the point

        proxy_read_timeout 120s;
        proxy_send_timeout 120s;
        client_max_body_size 4m;
    }
}

Stateless MCP architecture behind load balancerMCP client → round-robin load balancer → N stateless instances → business APIs, with OAuth/OIDC upstream

CDN cache for tools/list (SEP-2549)

tools/list responses can carry ttlMs and cacheScope. Server-side TypeScript:

return {
  tools: toolDefinitions,
  _meta: {
    'io.modelcontextprotocol/cache': {
      ttlMs: 300_000,
      cacheScope: 'shared',
    },
  },
};

Configure your CDN or nginx proxy_cache to respect these TTLs — less instance load, lower client latency.


Phase 5 — Breaking changes: complete checklist

Check each item before production cutover.

Transport and sessions

  • Remove all initialize / notifications/initialized logic
  • Remove Mcp-Session-Id header on client and server
  • Remove Redis / Map MCP session store at protocol level
  • Disable sticky sessions on load balancer
  • Migrate legacy HTTP+SSE to Streamable HTTP
  • Add GET /mcp/discover (or equivalent server/discover RPC)

Headers and routing

  • Require MCP-Protocol-Version: 2026-07-28
  • Send Mcp-Method and Mcp-Name on every Streamable HTTP POST
  • Reject header/body mismatches (400, not 500)
  • Configure rate limiting by Mcp-Method at gateway

Application state

  • Replace session state with explicit handles (workflow_id, etc.)
  • Document handles in tool descriptions (the model must see them)
  • Migrate elicitation to InputRequiredResult + requestState (SEP-2322)

API and schemas

  • Migrate inputSchema / outputSchema to JSON Schema 2020-12
  • Replace -32002 error matching with -32602 (SEP-2164)
  • Migrate experimental Tasks to Tasks extension (remove tasks/list)
  • Plan Roots / Sampling / Logging replacement (deprecated, 12-month minimum)

OAuth / security

  • Implement iss validation (SEP-2468) — high priority
  • Verify Dynamic Client Registration with OIDC application_type (SEP-837)
  • Test refresh tokens and step-up auth (SEPs 2207, 2350)

Observability

  • Propagate traceparent / tracestate in _meta (SEP-414)
  • Replace logging/setLevel with OpenTelemetry or structured stderr
  • Dashboards: p95 latency by Mcp-Method, 4xx vs 5xx rate

Estimated effort by migration phaseAudit ~2d, SDK+transport ~5d, load balancer ~3d, tests ~4d, OAuth ~3d, deployment ~2d — order of magnitude for a medium server


Phase 6 — Test procedures

Manual test with MCP Inspector

npx @modelcontextprotocol/inspector \
  --url https://mcp-v2.example.com/mcp \
  --header "MCP-Protocol-Version: 2026-07-28"

Verify in Inspector:

  1. server/discover returns 2026-07-28.
  2. tools/list works without a prior session.
  3. tools/call on each critical tool returns a valid result.
  4. Two consecutive calls can hit different instances (test via X-Debug-Instance header in dev).

CI test suite (Vitest + fetch)

// tests/mcp-stateless.integration.test.ts
import { describe, it, expect } from 'vitest';

const MCP_URL = process.env.MCP_TEST_URL ?? 'http://localhost:3000/mcp';
const HEADERS = {
  'Content-Type': 'application/json',
  'MCP-Protocol-Version': '2026-07-28',
};

describe('MCP 2026-07-28 stateless', () => {
  it('discover announces correct version', async () => {
    const res = await fetch(`${MCP_URL.replace('/mcp', '')}/mcp/discover`);
    const data = await res.json();
    expect(data.protocolVersions).toContain('2026-07-28');
  });

  it('tools/list without session', async () => {
    const res = await fetch(MCP_URL, {
      method: 'POST',
      headers: { ...HEADERS, 'Mcp-Method': 'tools/list' },
      body: JSON.stringify({
        jsonrpc: '2.0',
        id: 1,
        method: 'tools/list',
        params: { _meta: { 'io.modelcontextprotocol/protocolVersion': '2026-07-28' } },
      }),
    });
    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.result.tools.length).toBeGreaterThan(0);
  });

  it('rejects header/body mismatch', async () => {
    const res = await fetch(MCP_URL, {
      method: 'POST',
      headers: { ...HEADERS, 'Mcp-Method': 'tools/call', 'Mcp-Name': 'wrong_tool' },
      body: JSON.stringify({
        jsonrpc: '2.0',
        id: 2,
        method: 'tools/call',
        params: { name: 'search_contacts', arguments: { query: 'test' } },
      }),
    });
    expect(res.status).toBe(400);
  });

  it('stateless tools/call across independent requests', async () => {
    for (let i = 0; i < 5; i++) {
      const res = await fetch(MCP_URL, {
        method: 'POST',
        headers: {
          ...HEADERS,
          'Mcp-Method': 'tools/call',
          'Mcp-Name': 'search_contacts',
        },
        body: JSON.stringify({
          jsonrpc: '2.0',
          id: i,
          method: 'tools/call',
          params: {
            name: 'search_contacts',
            arguments: { query: 'acme' },
            _meta: { 'io.modelcontextprotocol/protocolVersion': '2026-07-28' },
          },
        }),
      });
      expect(res.status).toBe(200);
    }
  });
});

GitHub Actions integration:

# .github/workflows/mcp-migration-test.yml
name: MCP Stateless Migration Tests
on:
  pull_request:
    paths: ['src/mcp/**', 'tests/mcp/**']

jobs:
  integration:
    runs-on: ubuntu-latest
    services:
      mcp:
        image: ghcr.io/your-org/crm-mcp:${{ github.sha }}
        ports: ['3000:3000']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm ci
      - run: npm run test:mcp
        env:
          MCP_TEST_URL: http://localhost:3000/mcp

Post-migration load test

Validate round-robin holds load without sticky sessions:

hey -n 1000 -c 50 -m POST \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: tools/list" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
  https://mcp-v2.example.com/mcp

Success criteria: error rate < 0.1%, p95 latency ≤ baseline + 15%.


Phase 7 — Canary deployment and rollback

Four-step cutover strategy

  1. Week 1: deploy mcp-v2 alongside mcp-v1 (legacy 2025-11-25).
  2. Week 2: route internal clients (CI, staging) to v2; monitor 72 h.
  3. Week 3: canary 10% production traffic via header or subdomain.
  4. Week 4: 100% cutover; keep v1 read-only 2 weeks for rollback.

Rollback plan

Keep the old Docker image tagged mcp:2025-11-25-final. If error rate exceeds 1% post-cutover:

kubectl rollout undo deployment/mcp-server

Document the rollback window (max 30 minutes) in your incident runbook.


Phase 8 — Client-side MCP migration

Server migration alone is not enough: your clients (Cursor, n8n, custom agents) must stop sending Mcp-Session-Id and adopt the new headers. Here is a minimal TypeScript fetch wrapper for internal agents:

// lib/mcpClient2026.ts — minimal stateless client
const PROTOCOL = '2026-07-28';

export async function mcpCall<T>(
  baseUrl: string,
  method: string,
  params: Record<string, unknown>,
  name?: string
): Promise<T> {
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    'MCP-Protocol-Version': PROTOCOL,
    'Mcp-Method': method,
  };
  if (name) headers['Mcp-Name'] = name;

  const body = {
    jsonrpc: '2.0',
    id: crypto.randomUUID(),
    method,
    params: {
      ...params,
      _meta: {
        'io.modelcontextprotocol/protocolVersion': PROTOCOL,
        'io.modelcontextprotocol/clientInfo': { name: 'bovo-agent', version: '2.0.0' },
      },
    },
  };

  const res = await fetch(`${baseUrl}/mcp`, { method: 'POST', headers, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`MCP HTTP ${res.status}: ${await res.text()}`);
  const data = await res.json();
  if (data.error) throw new Error(data.error.message);
  return data.result as T;
}

For n8n, upgrade the MCP Client node to a 2026-07-28-compatible version and verify credentials no longer include session parameters. Test a simple tools/listtools/call workflow before re-enabling production workflows.

For Cursor and Claude Desktop, application updates usually suffice — Tier 1 clients adopt the spec within the ten-week post-RC window. Your local stdio servers benefit from automatic client updates without infrastructure changes.


Phase 9 — Kubernetes deployment without sticky sessions

In containerized production, stateless MCP simplifies horizontal scaling. Example Deployment + HPA:

# k8s/mcp-server.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mcp-server
spec:
  replicas: 3
  selector:
    matchLabels: { app: mcp-server }
  template:
    metadata:
      labels: { app: mcp-server }
    spec:
      containers:
        - name: mcp
          image: ghcr.io/your-org/crm-mcp:2026-07-28
          ports: [{ containerPort: 3000 }]
          env:
            - name: MCP_PROTOCOL_VERSION
              value: "2026-07-28"
          readinessProbe:
            httpGet: { path: /mcp/discover, port: 3000 }
            periodSeconds: 10
          livenessProbe:
            httpGet: { path: /mcp/discover, port: 3000 }
            periodSeconds: 30
          resources:
            requests: { cpu: "250m", memory: "512Mi" }
            limits: { cpu: "1", memory: "1Gi" }
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: mcp-server-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: mcp-server
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource: { name: cpu, target: { type: Utilization, averageUtilization: 70 } }

Important: do not add sessionAffinity: ClientIP on the Service — that reintroduces sticky routing the 2026-07-28 spec makes obsolete. Remove Ingress affinity cookies if you used them before.

The readiness probe on /mcp/discover ensures no instance joins the pool until it announces version 2026-07-28. Pair it with a PodDisruptionBudget (minAvailable: 2) for zero-downtime rolling deploys.


Phase 10 — Multi-round-trip elicitation (SEP-2322)

Servers that requested user confirmation via a persistent SSE connection must migrate to Multi Round-Trip Requests. Server-side handler example:

server.registerTool('delete_files', { /* schema */ }, async (args, extra) => {
  if (!args.confirmed) {
    return {
      resultType: 'inputRequired',
      inputRequests: {
        confirm: {
          type: 'elicitation',
          message: `Delete ${args.file_ids.length} files?`,
          schema: { type: 'boolean' },
        },
      },
      requestState: Buffer.from(JSON.stringify({ file_ids: args.file_ids })).toString('base64url'),
    };
  }
  await bulkDelete(args.file_ids);
  return { content: [{ type: 'text', text: 'Deletion complete' }] };
});

The client receives inputRequired, collects user input, then re-emits the original call with inputResponses and echoed requestState. Any pool instance can handle the retry — context lives entirely in the payload, not in transport session.

Test this flow in MCP Inspector by simulating two consecutive requests with the same requestState. It is the most overlooked migration scenario and the most visible to end users.


Phase 11 — Docker Compose for local test environment

Before touching production, reproduce a minimal local cluster:

# docker-compose.mcp-migration.yml
services:
  mcp-1:
    build: .
    environment: { MCP_PROTOCOL_VERSION: "2026-07-28", INSTANCE_ID: "1" }
  mcp-2:
    build: .
    environment: { MCP_PROTOCOL_VERSION: "2026-07-28", INSTANCE_ID: "2" }
  nginx:
    image: nginx:1.27-alpine
    ports: ["8443:443"]
    volumes:
      - ./nginx-mcp.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on: [mcp-1, mcp-2]

Run the CI test suite against https://localhost:8443/mcp and verify via INSTANCE_ID logs that requests alternate between containers — proof stateless works before cloud deployment.


Common errors and fixes

SymptomLikely causeFix
404 Unknown session after LBSticky removed but server keeps sessionsRemove session store
400 Header/body mismatchClient missing Mcp-NameUpgrade client SDK
Tools OK in dev, fail in prodGateway rate limit without Mcp-MethodConfigure nginx map (§4)
Tasks stuck mid-flightRemoved 2025-11-25 Tasks APIMigrate Tasks extension
Stale tools/list cacheCDN ignores ttlMsHonor _meta.cache

Connection to your existing AI agents

This transport migration does not replace agent design. If you are building a full agent — orchestration, HITL, front-end — our AI agent creation offering now includes MCP 2026-07-28 compatibility audit from scoping. For an operational agent connected to n8n and internal APIs, the deploy an MCP AI agent path remains the starting point; this tutorial is the natural follow-up when you move to multi-instance HTTP.


Conclusion — key takeaways

Migrating to stateless MCP 2026-07-28 is not rewriting your CRM or inventory tools: it is removing transport sessions, adding three HTTP headers, configuring round-robin load balancing, and externalizing state via explicit handles. The operational win is immediate: horizontal scaling without sticky sessions, CDN cache on lists, gateway routing without JSON-RPC parsing.

Final go-live checklist:

  1. Audit complete, breaking changes checked.
  2. Client and server SDK ≥ RC-compatible version.
  3. Inspector + CI tests green on canary.
  4. Load test 1000 req with zero session errors.
  5. Rollback runbook tested.

Migrating several MCP servers in parallel? Our team supports transport migrations with OAuth review, load balancer configuration, and CI test suites. Contact us for an MCP stack audit — we start by inventorying your breaking changes, not selling an unnecessary rewrite.

Need an AI agent compatible with 2026-07-28 from the POC? See our AI agent creation offering: protocol audit, handle patterns, OpenTelemetry observability included.

MCP server not built yet? Start from our TypeScript 30-minute tutorial targeting the 2026-07-28 spec directly — you avoid a double migration.

Tags

#MCP#TypeScript#Python#Tutorial#Migration#DevOps#AI Agents#2026

Share this article

LinkedInX

FAQ

Do I need to migrate my local stdio MCP server to the 2026-07-28 spec?

For a stdio server used only locally (Cursor, Claude Desktop), migration is mainly an SDK upgrade to a 2026-07-28-compatible @modelcontextprotocol/sdk version. Transport changes (Mcp-Session-Id, Mcp-Method headers) primarily affect remote Streamable HTTP. For stdio, focus on removing the initialize handshake, passing metadata in _meta, and validating JSON Schema 2020-12 schemas.

How do I test the migration without cutting production?

Deploy a canary instance on a subdomain (mcp-v2.example.com) behind the same load balancer with a separate pool. Run the MCP Inspector test suite in CI, then route 5–10% of traffic via an X-MCP-Version: 2026-07-28 header. Compare latency, error rate, and OpenTelemetry logs for 48 hours before full cutover.

What if my server depends on an MCP session Redis store?

Remove the protocol-level session store. If your business logic needs continuity (cart, headless browser, multi-step workflow), expose an explicit handle — workflow_id, browser_id — returned by a tool and passed back as an ordinary argument by the model. The protocol becomes stateless; your application can stay stateful via opaque IDs in a database or application Redis, not in MCP sessions.

Which HTTP headers are mandatory after migration?

Every Streamable HTTP POST request must carry MCP-Protocol-Version: 2026-07-28, Mcp-Method (e.g. tools/call), and Mcp-Name (tool or resource name). The server rejects requests where headers and JSON-RPC body diverge (SEP-2243). W3C traceparent propagation in _meta is recommended for observability (SEP-414).

How long does a typical stateless MCP migration take?

For an HTTP server already on Streamable HTTP with 5–15 tools: budget 2 days for audit, 3–5 days for transport and header refactor, 2 days for load balancer config, 3–4 days for CI and canary tests. Total: 10–15 person-days depending on debt (experimental Tasks, legacy HTTP+SSE, custom OAuth). A local stdio server often migrates in half a day via SDK upgrade.

Ready to implement this?

Book a free 30-min strategy call with our experts

We'll analyze your situation and propose a concrete action plan.

William Aklamavo

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

Take action with BOVO Digital

This article sparked ideas? Our experts guide you from strategy to production.

Related articles