MCP stateless con SEP-1442: perché rifare il tuo server prima della spec di giugno 2026

MCP stateless con SEP-1442: perché rifare il tuo server prima della spec di giugno 2026

Il 22 aprile 2026 ho fatto una drain su due delle tre istanze del mio MCP server di produzione, un Node.js 22 LTS su Hetzner CPX41 (8 vCPU EPYC 7702, 16 GB RAM, 240 GB NVMe) dietro un HAProxy 3.0 con sticky session basate su Mcp-Session-Id. Il drain doveva essere zero-downtime: le connessioni correnti dovevano terminare sull'istanza che drainava, le nuove dovevano andare alle altre due. Nella pratica, sei client MCP (quattro Claude Code su laptop diversi, due Cursor su workstation) hanno visto errori MCP session not found: abc123. Sticky session falliti perché la sessione era legata a un'istanza specifica che stava per spegnersi, e la logica di riconnessione client aveva generato un nuovo session id verso un backend diverso che non aveva stato. Il sintomo è il solito della statefulness: puoi scalare orizzontalmente, ma ogni scale-out event o drain genera un blip.

Il 13 marzo 2026 è stata aperta la proposta SEP-1442 "Make MCP Stateless (by default)", autori Jonathan Hefner, Mark Roth, Shaun Smith, Harvey Tuch, Kurtis Van Gent. Status: Draft, Standards Track. Target: inclusione nella specifica MCP di giugno 2026, annunciata come prossimo spec release dalla 2026 MCP Roadmap pubblicata da David Soria Parra a marzo 2026. Se il tuo MCP server è in produzione dietro load balancer, hai circa due mesi per rifare l'architettura senza andare in fire drill. Ti mostro cosa cambia, come preparare il tuo server Node o Python stateless fin da ora, e come gestire la transizione mantenendo compatibilità con i client vecchi.

Cosa rompe oggi di uno stateful MCP

Il protocollo MCP attuale (release 25 novembre 2025) ha due vincoli di stateful implicito.

Primo: handshake initialize obbligatorio. Ogni client apre una connessione Streamable HTTP, invia initialize con la propria protocol version e capabilities, riceve dal server la server capability list, e solo dopo questo scambio le richieste successive (tools/list, tools/call, resources/list, ...) funzionano. Il server deve ricordare la session: client X ha sessione Y, negoziata al tempo T, con capabilities Z.

Secondo: session id legato al transport. Il Mcp-Session-Id header è creato dal server durante initialize e deve essere presente in ogni request successiva dello stesso client. Se il request va a un server diverso (load balancer round-robin), il server non conosce il session id e restituisce errore.

Conseguenze operative. Load balancer stateless (L4 round-robin, L7 hash) non funzionano: servono sticky session con hash sul session id, che richiede Layer 7 e logica custom. Il drain di un nodo richiede gestione particolare: o il LB tiene viva la sessione finché il client non la chiude (e devi aspettare), o uccidi la sessione e il client deve re-inizializzare. Horizontal scaling è possibile solo con session storage distribuito (Redis, DynamoDB): ogni server scrive/legge lo stato della sessione da un backing store condiviso, con overhead di rete per ogni request. Server restart perde tutte le sessioni correnti: i client vedono errori fino a quando non fanno initialize di nuovo.

Se stai costruendo infrastruttura agentic AI su MCP e vuoi capire come architetture scalabili si sposano con governance agentic seria, nel mio hub dedicato all'AI per aziende trovo articoli che spiegano come integro Laravel + MCP server custom in pipeline di produzione.

SEP-1442: cosa propone esattamente

La proposta è un cambio filosofico in una riga: ogni request MCP contiene tutto il contesto necessario per essere processata, indipendentemente da request precedenti o successive. Concretamente, tre modifiche operative.

Prima modifica. L'handshake initialize non è più obbligatorio. Il client può inviare direttamente tools/list, tools/call, etc. Il server risponde con la lista dei tool (o l'esecuzione del tool), senza bisogno di sapere chi è il client o da dove viene. Le capability negotiation, quando serve, diventa un header opzionale Mcp-Client-Capabilities inviato a ogni request: stateless, self-contained.

Seconda modifica. Mcp-Session-Id diventa opzionale e gestito dal client (non assegnato dal server). Se il client vuole una sessione persistente per feature come subscription o long-lived streaming, il client sceglie un identifier e lo passa a ogni request; il server usa quell'id per raggruppare le request ma non ha bisogno di "conoscere" il client prima. Pattern equivalente a come i cookie funzionano nel web: il browser li gestisce, il server li consuma.

Terza modifica. Un endpoint .well-known/mcp serve le capability del server come metadata JSON statico, invece di richiedere una connessione live. Il client (o un registry, o un crawler) fa GET /.well-known/mcp, riceve un documento con tool list, resource list, versione protocollo, features opzionali, senza aprire una sessione. È il pattern robots.txt/ai.txt portato al protocollo MCP.

La proposta non elimina lo stateful MCP: lo rende opt-in. Feature che richiedono stato (subscription a resource, progress notification, long-lived streaming) restano disponibili sotto flag Stateful: true nel request. Il default diventa stateless.

Come cambierà il tuo codice Node.js

Prendo come baseline un server MCP Node costruito con @modelcontextprotocol/sdk 1.x pre-stateless. Pattern tipico:

// Versione pre-SEP-1442: stateful, richiede initialize, sticky session
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';

const server = new Server({ name: 'my-tools', version: '1.0.0' }, {
  capabilities: { tools: {} }
});

// Session state tenuto in memoria server-side
const sessions = new Map();

server.setRequestHandler('initialize', async (req) => {
  // Il server ricorda questo client per tutte le chiamate successive
  sessions.set(req.sessionId, { clientCapabilities: req.params.capabilities });
  return { protocolVersion: '2025-11-25', capabilities: { tools: {} } };
});

server.setRequestHandler('tools/list', async (req) => {
  if (!sessions.has(req.sessionId)) throw new Error('MCP session not found');
  return { tools: [/* ... */] };
});

La versione stateless SEP-1442 compatible elimina sessions.Map e rende ogni handler self-contained:

// Versione SEP-1442 compatible: stateless by default
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';

const server = new Server(
  { name: 'my-tools', version: '2.0.0' },
  { capabilities: { tools: {} }, stateless: true }, // Flag esplicito stateless
);

server.setRequestHandler('tools/list', async (req) => {
  // Nessuna sessione da verificare: ogni request contiene il contesto necessario
  // Le capabilities del client arrivano nel header opzionale Mcp-Client-Capabilities
  const clientCaps = req.headers['mcp-client-capabilities'] ?? {};

  return { tools: listToolsFor(clientCaps) };
});

server.setRequestHandler('tools/call', async (req) => {
  // Self-contained: nome tool + argomenti + eventuali auth nel body
  const { name, arguments: args } = req.params;

  // Authentication nel header, non nella sessione
  const authToken = req.headers['authorization'];
  if (!isAuthorized(authToken, name)) {
    throw new Error('Unauthorized');
  }

  return { content: await executeTool(name, args) };
});

// Endpoint .well-known/mcp per discovery senza connessione
app.get('/.well-known/mcp', (req, res) => {
  res.json({
    protocolVersion: '2026-06-01',
    server: { name: 'my-tools', version: '2.0.0' },
    capabilities: { tools: { list: '/mcp/tools/list', call: '/mcp/tools/call' } },
    tools: listTools().map(t => ({ name: t.name, description: t.description })),
  });
});

La rimozione della Map di sessione permette di mettere N istanze dietro un load balancer L4 round-robin senza configurazione speciale. Il drain di un nodo non richiede più sticky-session aware: una request in corso finisce sull'istanza che sta drainando, la successiva va a un'altra istanza qualsiasi, nessun errore session not found.

Come cambierà il tuo codice Python

Pattern simmetrico con FastAPI e modelcontextprotocol-python SDK:

from fastapi import FastAPI, Header, HTTPException
from mcp.server import Server
from mcp.server.stateless import StatelessTransport
from typing import Annotated

app = FastAPI()

# Server dichiara stateless=True: ogni handler deve essere self-contained
server = Server(
    name="my-tools",
    version="2.0.0",
    stateless=True,
)

@server.list_tools()
async def list_tools(
    # Le capability del client arrivano come header opzionale
    client_caps: Annotated[str | None, Header(alias="Mcp-Client-Capabilities")] = None,
):
    # Nessun accesso a session state: la lista e' derivata dall'header o dal default
    return build_tool_list(client_caps)


@server.call_tool()
async def call_tool(
    name: str,
    arguments: dict,
    authorization: Annotated[str | None, Header()] = None,
):
    # Autorizzazione per-request, non per-session
    if not is_authorized(authorization, name):
        raise HTTPException(status_code=401, detail="Unauthorized")

    return await execute_tool(name, arguments)


@app.get("/.well-known/mcp")
async def mcp_discovery():
    # Endpoint discovery statico: nessuna connessione MCP richiesta
    return {
        "protocolVersion": "2026-06-01",
        "server": {"name": "my-tools", "version": "2.0.0"},
        "capabilities": {"tools": {"list": "/mcp/tools/list", "call": "/mcp/tools/call"}},
        "tools": [{"name": t.name, "description": t.description} for t in all_tools()],
    }


# Monta il transport stateless HTTP
app.mount("/mcp", StatelessTransport(server))

L'approccio FastAPI rende esplicito cosa serve per ogni handler: header, argomenti, auth. Niente global state. Questo rende anche il testing molto più semplice: ogni handler è una pure function che prende input e restituisce output.

Session opt-in: quando serve ancora lo stateful

Due pattern che SEP-1442 non elimina, ma rende opzionali.

Primo pattern: subscription a resource. Se il tuo tool espone una resource che cambia nel tempo (es. log tail, live metrics) e il client vuole essere notificato dei cambiamenti, hai bisogno di una connessione long-lived con stato server-side che ricordi "questo client è subscribed a resource X". Nella versione SEP-1442, questa è una feature opt-in: il client manda Mcp-Stateful: true in initialize (che resta disponibile come modalità legacy), il server mantiene lo stato solo per quei client.

Secondo pattern: long-running tool execution. Tool che impiegano minuti a completarsi e inviano progress notification. Anche qui, il pattern è stateful, ma limitato al singolo tool call: il server ricorda il job_id e notifica quando è pronto, senza sessione di lunga durata.

Il 90% dei MCP server non ha bisogno di nessuno dei due pattern. Tool di lettura file, query database, shell exec, API call esterna: tutti stateless per natura. Se il tuo server rientra in questo 90%, la migrazione SEP-1442 è solo pulizia.

Capability discovery via .well-known

Il nuovo endpoint /.well-known/mcp abilita casi d'uso che oggi richiedono connessione live. Un MCP registry pubblico (tipo github.com/modelcontextprotocol/registry) può crawlare server senza aprire sessioni, semplicemente chiedendo al .well-known la lista dei tool disponibili. Un gateway MCP (API Gateway) può enrichire il routing con metadata lette all'avvio. Un assistant autonomo che esplora server pubblici può fare discovery a costo zero: zero socket aperti, zero round-trip stateful.

Il formato è JSON strutturato con schema definito nella SEP. Il campo protocolVersion identifica la versione MCP supportata; il campo capabilities elenca i gruppi di feature (tools, resources, prompts) con l'endpoint HTTP per ciascuno; il campo tools è un'enumerazione ridotta con nome, descrizione, inputSchema minimo. La full schema dei tool resta disponibile via tools/list ma il riassunto è sufficiente per discovery.

Test harness di compatibilità

Prima di spingere stateless in produzione, ho costruito un test harness minimo che invia le stesse 20 richieste al server vecchio (stateful) e al server nuovo (stateless) e confronta output.

import httpx
import asyncio

async def compare_responses(tool: str, args: dict) -> bool:
    async with httpx.AsyncClient() as client:
        # Server legacy con handshake
        legacy_init = await client.post("https://mcp-legacy.test/init", json={"method": "initialize"})
        session_id = legacy_init.headers["mcp-session-id"]
        legacy_response = await client.post(
            "https://mcp-legacy.test/tools/call",
            headers={"Mcp-Session-Id": session_id},
            json={"method": "tools/call", "params": {"name": tool, "arguments": args}},
        )

        # Server stateless: niente init, chiamata diretta
        stateless_response = await client.post(
            "https://mcp-stateless.test/mcp/tools/call",
            json={"method": "tools/call", "params": {"name": tool, "arguments": args}},
        )

        # Confronto strutturale degli output (stessa semantica, stessa shape)
        return legacy_response.json()["content"] == stateless_response.json()["content"]


async def run_parity_suite():
    test_cases = [
        ("read_file", {"path": "/etc/hostname"}),
        ("list_dir", {"path": "/tmp"}),
        ("bash_exec", {"command": "date"}),
        # Altri 17 test case...
    ]

    results = await asyncio.gather(*[compare_responses(t, a) for t, a in test_cases])
    failed = [test_cases for i, ok in enumerate(results) if not ok]

    if failed:
        print(f"PARITY FAILURE: {len(failed)} cases diverged")
        for t, a in failed:
            print(f"  - {t}({a})")
        return False

    return True

Eseguo questa parity suite prima di ogni deploy durante la transizione. Zero tolerance per divergenze: se anche un tool restituisce output diverso tra le due versioni, non promuovo il cambiamento.

La roadmap operativa dei prossimi 60 giorni

Se sei responsabile di un MCP server in produzione, il piano realistico è:

Settimana 1-2: inventario dei tool esposti, identificazione di quelli che dipendono davvero da session state (tipicamente 0-2 tool su 30-50 totali).

Settimana 3-4: implementazione del branch stateless del server, con feature flag --stateless che attiva il nuovo path. Parity suite in CI.

Settimana 5-6: deploy stateless in ambiente staging, test con client reali (Claude Code, Cursor, OpenCode). Misura di latenza p95, error rate, session error count.

Settimana 7-8: progressive rollout in produzione tramite weighted load balancer (10%, 25%, 50%, 100%). Rollback plan pronto.

Settimana 9+: deprecation del path stateful, rimozione del codice legacy dopo il June 2026 spec release.

Una nota sui SDK ufficiali. Il TypeScript SDK (@modelcontextprotocol/sdk) già espone un'opzione stateless: true nel costruttore del server dalla release 1.8.0 di febbraio 2026, ma la semantica esatta non è ancora allineata alla SEP-1442: per esempio, alcune feature come resource subscription restano stateful anche con quel flag. Il Python SDK (modelcontextprotocol-python) ha un'implementazione analoga in stateless_experimental.py aggiunta a marzo 2026. La cosa importante è che le tue chiamate ai handler non dipendano da state globale custom: se scrivi codice self-contained oggi, l'upgrade allo standard finale di giugno sarà una config change, non un rewrite. Se invece hai accumulato logica che dipende implicitamente dalla persistenza di sessione (cache per-client, counter, rate limiting interno basato su session id), quella parte va rifattorizzata a livello di dato, non di sessione: sposta il rate limiting in Redis, sposta le cache in un layer distribuito, e tratta la sessione come un identifier opaco passato dal client, non come un contenitore di stato.

Se il tuo team non ha banda per questa roadmap e stai gestendo MCP server che alimentano sistemi agentic production-critical, il modulo di preventivo gratuito risponde in due minuti se il tuo scenario rientra nel mio perimetro. Sette domande, niente impegno.

Ultima modifica: