MCP è diventato stateless: cosa ha tolto la revisione 2026-07-28 dal nostro server

Se gestisci un server MCP, hai una sessione da qualche parte. Probabilmente una tabella, forse una map in memoria. Un client si connette, manda initialize, si vede tornare un Mcp-Session-Id, e da lì in poi si porta dietro quell'header su ogni request. Tu memorizzi la riga. La fai scadere dopo un po'. Ti assicuri che una request atterri sull'istanza che la possiede, oppure condividi lo stato tra le istanze.

La revisione 2026-07-28 ha cancellato tutto questo. Non l'ha deprecato: l'ha rimosso dal cuore del protocollo. L'handshake non c'è più, l'header di sessione non c'è più, e ogni request porta ora la propria versione di protocollo e la propria identità di client. Come lo mette il post di release, "any request can now land on any server instance behind a plain round-robin load balancer without needing shared storage" — qualsiasi request può ora atterrare su qualsiasi istanza del server dietro un semplice load balancer round-robin, senza bisogno di storage condiviso.

Il server MCP di Archyl ora serve i client che parlano la nuova revisione. Questo post è cosa è servito, cosa abbiamo misurato dopo, l'unica cosa che abbiamo sbagliato al primo giro, e cosa non abbiamo fatto. Se mantieni un server MCP, le parti interessanti sono probabilmente la decisione di design nel mezzo, il bug che l'audit del nuovo transport ha fatto emergere nel vecchio, e la checklist finale per andare a guardare il tuo.

Cosa ha rimosso davvero la revisione

Direttamente dal changelog, le parti che toccano l'implementazione di un server:

  • Le sessioni a livello di protocollo e l'header Mcp-Session-Id sono rimossi dal transport Streamable HTTP. Gli endpoint di listing non variano più per connessione.
  • L'handshake initialize / notifications/initialized è rimosso. Ogni request porta la propria versione di protocollo e le capability del client in _meta, e su Streamable HTTP la stessa versione viaggia nell'header MCP-Protocol-Version.
  • server/discover è nuovo ed è obbligatorio. I server DEVONO implementarlo, per annunciare le versioni di protocollo supportate, le capability e l'identità. I client POSSONO chiamarlo prima di ogni altra cosa; sono altrettanto liberi di mandare una request e gestire un errore di versione.
  • ping, logging/setLevel e notifications/roots/list_changed sono rimossi.
  • I disallineamenti di versione restituiscono UnsupportedProtocolVersionError, elencando le versioni che il server supporta così che il client possa riprovare.

Là dentro c'è dell'altro (Multi Round-Trip Requests, subscriptions/listen, risultati di listing cacheabili, un blocco di codici di errore rinumerati, irrigidimento dell'authorization), e tornerò su quali di quelle abbiamo fatto e quali abbiamo saltato. Le cinque qui sopra sono quelle che cambiano la forma di un server, non le sue funzionalità.

Un punto su cui vale la pena essere precisi, perché cambia la decisione: questa non è più una release candidate. La release candidate è stata congelata il 21 maggio 2026 e ha aperto una finestra di validazione di dieci settimane per i maintainer degli SDK e per chi implementa i client. Quella finestra si è chiusa il 28 luglio 2026, quando la specifica è uscita, e la pagina di versioning ora chiama 2026-07-28 "the current protocol version" — la versione corrente del protocollo. Tutti e quattro gli SDK Tier 1 (TypeScript, Python, Go, C#) la parlano dal giorno del rilascio, con Rust in beta. Se stavi aspettando che la RC si assestasse, si è assestata.

Cosa ha significato per un server con 181 tool

Il server MCP di Archyl espone 181 tool sul modello C4: progetti, sistemi, container, component, relationship, ADR, doc, contract, conformance, drift, DORA, ownership. Prima di questo cambiamento, tutti e 181 stavano dietro una sessione.

Concretamente, nel nostro backend:

  • Ogni connessione creava una riga in una tabella mcp_sessions, con scadenza a 24 ore e una goroutine in background che ripuliva le righe stantie e scadute.
  • I canali di risposta SSE vivevano in una map[string]chan *JSONRPCMessage sullo struct del server, indicizzata per session ID, il che inchiodava una connessione al processo che l'aveva aperta. Quella map nel frattempo si è spostata, e il motivo si è rivelato essere un bug più che una preferenza. Ci torno più sotto.
  • Quattro handler (tools/list, tools/call, resources/list, resources/read) si aprivano con le stesse tre righe:
if !session.Initialized {
	return s.errorResponse(msg.ID, ErrCodeInvalidRequest, "Session not initialized", nil)
}

Quel guard è quello interessante. Pone una domanda che il nuovo protocollo ha reso senza risposta: questo chiamante ha completato l'handshake? Non c'è nessun handshake da completare.

La decisione che ha tenuto piccola la modifica

La mossa allettante è insegnare la statelessness a quei quattro handler. Aggiungere una seconda condizione, o un session.Stateless || davanti a ogni controllo, o sollevare tutto quanto in un middleware.

Non abbiamo fatto niente di tutto ciò. I guard sono intatti. Al loro posto, una request che dichiara 2026-07-28 ottiene una sessione in memoria costruita per quella singola request, che soddisfa il guard per costruzione:

func (s *Server) NewStatelessSession(userID, organizationID uuid.UUID, protocolVersion string) *Session {
	now := time.Now()
	return &Session{
		Session: &mcpsession.Session{
			ID:              "",
			UserID:          userID,
			OrganizationID:  organizationID,
			Initialized:     true,
			ProtocolVersion: protocolVersion,
			Transport:       "streamable",
			LastAccessedAt:  now,
			CreatedAt:       now,
		},
		Stateless: true,
	}
}

Niente viene persistito. Nessun ID viene allocato. Nessun canale SSE viene registrato. Initialized: true non è una bugia né un bypass: sotto questa revisione la request è genuinamente inizializzata, perché il protocollo porta con sé la propria versione e l'handshake che altrimenti avrebbe completato non esiste più.

Perché questa inquadratura conta più di quanto sembri: quei quattro guard stanno su un percorso adiacente all'autorizzazione. Ognuno di loro è la differenza tra una chiamata a un tool che gira e una che viene rifiutata. Modificare quattro punti di chiamata che rispondono tutti a una domanda di forma securitaria sono quattro occasioni per indebolire un controllo, sparse su un diff che chi rivede deve tenere in testa tutto insieme. Costruire l'oggetto che i guard già si aspettano è una funzione nuova, e ogni controllo esistente mantiene il suo significato esatto.

Fallisce anche in una direzione sicura. Se il nostro rilevamento di versione sbaglia e una request stateless viene letta come una legacy, la conseguenza è che le viene creata una riga di sessione. Non passa niente che non sarebbe passato prima. Il design opposto, allentare i guard e condizionarli a una stringa di versione, fallisce dall'altra parte.

La statelessness non ci è costata neanche un po' di tenancy, perché l'identità non è mai arrivata dalla riga di sessione, tanto per cominciare. La sessione stateless porta l'utente e l'organizzazione risolti dall'API key o dal token OAuth presentati su quella request, gli scope vengono riderivati a ogni chiamata quindi revocare una key ha effetto immediato, e tools/call continua a rifiutare una sessione senza un tenant associato. Ora c'è una cosa in meno da rubare: nessun session ID memorizzato che possa essere rigiocato. Sul percorso legacy abbiamo mantenuto il controllo corrispondente, così un session ID non può permettere a una credenziale di agire con l'identità memorizzata nella sessione di qualcun altro.

Il routing, in uno switch

L'intera decisione vive nell'handler HTTP, prima ancora che il body JSON-RPC venga parsato:

switch {
case mcp.IsModernProtocolVersion(requestedVersion):
	// Stateless: the request describes itself, so nothing is looked up,
	// nothing is written, and no Mcp-Session-Id comes back.
	session = h.mcpServer.NewStatelessSession(auth.UserID, auth.OrganizationID, requestedVersion)

case sessionID != "":
	// Handshake-based client with a session: look it up, and check it
	// belongs to this credential.

default:
	// Legacy client that has not handshaken yet: mint a session as before.
}

Due dettagli lì dentro che è facile perdersi:

IsModernProtocolVersion è un confronto tra stringhe contro "2026-07-28". Le revisioni sono YYYY-MM-DD, quindi l'ordine lessicale è l'ordine cronologico, e una revisione futura atterra di default dal lato stateless invece di ricadere sull'handshake. Ci arriva solo se la supportiamo: una versione non riconosciuta viene rifiutata prima dello switch, con la lista di quelle supportate nei dati dell'errore così che il client possa riprovare.

E l'header di risposta:

if !session.Stateless {
	c.Set("Mcp-Session-Id", session.ID)
}

Una sessione stateless non ha ID. Rimandare indietro un Mcp-Session-Id vuoto direbbe a un client di riutilizzare qualcosa che non esiste, che è un bug peggiore del non mandarlo, e il tipo di cosa che salta fuori solo contro un client che non hai scritto tu.

Il resto del changelog, letto per bene

Lo switch sulla versione è la decisione interessante. Il resto della revisione è una lista di piccoli requisiti facili da perdere e a basso costo da verificare, quindi siamo tornati sul changelog riga per riga. Quattro di quelli sono atterrati in quel giro.

resultType su ogni result. La revisione rende il campo obbligatorio: "complete" per una risposta conclusa, "input_required" per il risultato intermedio nel pattern multi round-trip. Ai client viene detto di trattare la sua assenza da un server più vecchio come "complete", ma un client che legge la revisione finale lo cerca. I nostri ora ce l'hanno, da un unico struct Result incorporato in ogni tipo di risultato invece che ogni tipo si ricordi il campo per conto suo.

ttlMs e cacheScope sui risultati di listing. Obbligatori su tools/list, prompts/list, resources/list, resources/read e resources/templates/list, tramite una nuova interfaccia CacheableResult. Noi ospitiamo tre di quei cinque, e restituiscono 60000 e private. Sessanta secondi sono un suggerimento, non un contratto: abbastanza lunghi da impedire a un agente di rilistare 181 tool a ogni turno, abbastanza corti perché un tool registrato a metà sessione compaia in fretta. private è una decisione, non un default che abbiamo accettato. Ogni risultato che restituiamo è limitato all'organizzazione del chiamante, quindi nessun intermediario condiviso può cacharne uno e consegnarlo a un tenant diverso.

DELETE /mcp da un client che dichiara 2026-07-28. DELETE terminava una sessione a livello di protocollo, e sessioni a livello di protocollo non ce ne sono più. La specifica dice di rispondere 405, e quindi è quello che un client moderno riceve. Un client basato sull'handshake mantiene il vecchio comportamento.

Un metodo non implementato ora restituisce HTTP 404 che porta il JSON-RPC -32601. Il codice di stato da solo è ambiguo: anche un server legacy HTTP+SSE che l'endpoint moderno non lo ospita affatto risponde 404. È il body JSON-RPC a distinguere i due, e la specifica è esplicita sul fatto che un client lo usa per decidere se ricadere su initialize o riprovare.

E una che abbiamo sbagliato al primo giro

Il nostro errore di versione non supportata restituiva -32600, il generico "invalid request" di JSON-RPC. Era difendibile fino esattamente a questa revisione, che definisce una policy di allocazione dei codici di errore che spacca l'intervallo degli errori server di JSON-RPC: da -32000 a -32019 resta definito dall'implementazione, da -32020 a -32099 appartiene alla specifica. I codici introdotti durante il draft sono stati rinumerati dentro quel blocco. HeaderMismatch è passato -32001-32020, MissingRequiredClientCapability -32003-32021, e UnsupportedProtocolVersion -32004-32022.

Un client scritto contro la revisione finale cerca -32022. Non avrebbe riconosciuto quello che stavamo mandando, e la modalità di fallimento è proprio quella che tutta questa revisione è progettata per evitare: il client non riesce a distinguere "versione sbagliata, ecco quelle che parlo" da "la tua request era malformata", quindi non ha niente con cui riprovare.

Niente ha intercettato quella cosa se non rileggere il changelog una seconda volta, che è l'argomento stesso di questo post puntato contro di noi. La rinumerazione è il punto 12 delle modifiche minori, dopo le voci sulle chiavi _meta di OpenTelemetry e sulle keyword di JSON Schema. È il tipo di riga che si scorre.

Una rinomina non ci è costata niente. Resource-not-found si è spostato da -32002 a -32602, per allinearsi all'"invalid params" di JSON-RPC, e resources/read rispondeva già -32602 per una URI sconosciuta.

Cosa abbiamo misurato

Tutto questo è stato misurato contro un container in esecuzione su questa build, con una API key vera, così da poter contare le righe in Postgres direttamente.

Test Risultato
tools/list con MCP-Protocol-Version: 2026-07-28, senza handshake 181 tool
Mcp-Session-Id restituito su quella risposta nessuno
resultType su tools/list e su server/discover complete
ttlMs / cacheScope su tools/list 60000 / private
server/discover ["2026-07-28", "2025-03-26"]
Versione non supportata dichiarata -32022, lista delle supportate nei dati dell'errore
DELETE /mcp da un client che dichiara 2026-07-28 405
Metodo sconosciuto 404 che porta -32601
Handshake initialize legacy funziona ancora
tools/list legacy con un session id 181 tool
Righe mcp_sessions create da 10 request stateless 0
Righe mcp_sessions create da 3 request legacy 3

L'ultima coppia è quella da guardare. Dieci request, nessuna riga. Le tre request legacy sono arrivate ognuna senza session ID, quindi ognuna ne ha creata una; un client basato sull'handshake che si comporta bene e riusa il proprio ID ottiene una riga per tutta la vita della sua sessione, non una per chiamata. Il punto è lo zero: sul percorso stateless non c'è niente da scrivere, niente da far scadere, e niente che la goroutine di pulizia possa trovare.

La request che ha prodotto la prima riga di quella tabella, puntata sull'endpoint pubblico:

curl -s https://api.archyl.com/mcp \
  -H "X-API-Key: $ARCHYL_API_KEY" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Nessun initialize. Nessuna sessione. 181 tool.

Il bug che il transport deprecato stava nascondendo

Fare l'audit del nuovo transport ci ha portati a guardare il vecchio, e il vecchio aveva un bug vero.

Il transport HTTP+SSE del 2024-11-05 spezza una conversazione su due connessioni. Il client apre uno stream a lunga durata con GET, il primo evento del server gli dice dove fare POST, e da lì in poi ogni messaggio esce su un POST mentre ogni risposta torna sullo stream. Quelle due connessioni non devono per forza atterrare sulla stessa istanza.

Il nostro dava per scontato che lo facessero. I canali di risposta vivevano in quella map[string]chan *JSONRPCMessage sullo struct del server, quindi un POST gestito dall'istanza B scriveva la sua risposta in un canale che esisteva sull'istanza B e che sull'istanza B non stava leggendo nessuno. Lo stream era sull'istanza A. Il client aspettava.

Quello che rende la cosa peggiore di un odore di design è che non veniva loggato niente. Nessun errore, nessun warning, nessuna request fallita. Il POST restituiva 202 Accepted, il che era vero, il messaggio era stato accettato, e la risposta non andava da nessuna parte. Da fuori è indistinguibile da una chiamata a un tool lenta. Succede solo su un deployment scalato orizzontalmente, che è esattamente il posto in cui hai meno voglia di riprodurre qualcosa a mano.

La map ora è un router Redis pub/sub in streamrouter.go. Una risposta per uno stream che questo processo tiene aperto viene consegnata direttamente e non fa mai il giro. Una risposta per uno stream tenuto altrove viene pubblicata su mcp:stream:<sessionID>, e l'istanza che tiene quello stream è iscritta a quel canale. Qualsiasi istanza può prendere il POST. Non serve nessuna affinità di sessione, e non c'è nessuna regola di sticky session da mantenere in una config di load balancer che nessuno si ricorda di aver scritto.

Ci sono due cose che vale la pena dire al riguardo, perché un router è una dipendenza.

Redis ora sta su questo percorso. Se all'avvio è irraggiungibile, il router ricade sulla consegna solo locale e logga un warning invece di rifiutarsi di partire, perché solo-locale è corretto per una singola istanza ed è sbagliato solo da quando ne esiste una seconda. Il fallimento è rumoroso apposta: l'alternativa è il blocco silenzioso che abbiamo appena tolto. Se fai il deploy di questa cosa, la riga di avvio da cercare è MCP stream router: Redis connected. La sua assenza è tutta la storia.

E il router risolve il routing, non la posizione. Lo stream resta una connessione tenuta da un processo; Redis porta le risposte fino a quel processo, non sposta lo stream. Quella parte è irriducibile. Una connessione aperta vive dove è stata aperta, in qualsiasi protocollo.

Cosa non abbiamo fatto

È qui che di solito un annuncio si ferma. Due cose vale la pena dirle chiaramente, perché puoi verificarle entrambe.

Archyl parla 2026-07-28 sul percorso che conta. Non è stateless da capo a fondo.

Il percorso stateless è genuinamente stateless: nessuna lookup di sessione, nessuna scrittura di sessione, nessun Mcp-Session-Id, niente che inchiodi una request a un processo. Quel percorso può stare dietro un semplice load balancer round-robin.

Il nostro server risponde ancora anche sul più vecchio transport HTTP+SSE su /sse, ma abbiamo smesso di documentarlo. Ogni pagina che prima stampava quell'URL adesso stampa /mcp, ed è l'unico endpoint che chiediamo a qualcuno di configurare.

Il motivo è la dipendenza che abbiamo appena aggiunto. Il router toglie il requisito di affinità solo dove Redis è raggiungibile. Dove non lo è, la consegna ricade su solo-locale, che è corretto con una istanza e silenziosamente sbagliato con due. La nostra produzione oggi non fa girare Redis, quindi il fallback è quello che stiamo eseguendo. Preferiamo indirizzare tutti sul transport la cui correttezza non dipende da un numero di istanze, piuttosto che pubblicarne uno la cui correttezza invece ne dipende.

Quello che resta vero di /sse ovunque giri: lo stream è una connessione tenuta da un singolo processo, e una riga di sessione esiste in Postgres per tutta la sua durata. Togliere il requisito di affinità non è la stessa cosa che togliere lo stato. Non stiamo annunciando una data per ritirare quel transport.

L'orologio di quel transport però non è il nostro, ed è più corto di quanto pensassimo. HTTP+SSE è deprecato dalla revisione 2025-03-26; quello che 2026-07-28 ha fatto è stato riclassificarlo come Deprecated sotto la nuova policy di ciclo di vita delle funzionalità. Quella policy fissa una finestra minima di dodici mesi tra la deprecazione e l'idoneità alla rimozione, che è quello che ottengono Roots, Sampling e Logging: rimozione più prossima in "the first revision released on or after 2027-07-28" — la prima revisione rilasciata il 28 luglio 2027 o dopo. HTTP+SSE non ottiene dodici mesi, perché era già deprecato molto prima che la policy esistesse. Il registro delle funzionalità deprecate indica come rimozione più prossima "Three months after SEP-2596 reaches Final" — tre mesi dopo che SEP-2596 raggiunge lo stato Final. La rimozione resta una decisione dei Core Maintainer presa in fase di preparazione della release e può avvenire più tardi, ma se stai facendo girare HTTP+SSE da qualche parte, quella è la riga da leggere.

Abbiamo implementato la forma della revisione, non tutta quanta. Quello che esce è la negoziazione di versione, il percorso di request stateless, server/discover, l'errore di versione non supportata con il codice giusto, resultType, i suggerimenti di cache, e il 405 e il 404 che il transport richiede, accanto al percorso handshake per i client che ne hanno ancora bisogno. Ecco cosa non c'è:

  • Gli header di request Mcp-Method e Mcp-Name, e la validazione che ci va insieme. Questo è il buco più grande. La revisione richiede che un POST rispecchi il proprio method, e il proprio params.name o params.uri, dentro degli header, e richiede che il server rifiuti qualsiasi disallineamento con 400 e -32020 HeaderMismatch. Il motivo non è l'ordine formale. Nelle parole della specifica stessa, questo "prevents potential security vulnerabilities when different components in the network rely on different sources of truth (e.g., a load balancer routing on the header value while the MCP server executes based on the body value)" — previene potenziali vulnerabilità di sicurezza quando componenti diversi nella rete si affidano a fonti di verità diverse (per esempio un load balancer che instrada sul valore dell'header mentre il server MCP esegue in base al valore del body). La stessa regola copre MCP-Protocol-Version, il cui valore DEVE coincidere con quello nel _meta della request. Noi leggiamo la versione solo dall'header e non guardiamo mai _meta, quindi non possiamo rilevare un disallineamento che siamo tenuti a rifiutare. L'header è disponibile prima che il body venga parsato, ed è per questo che lo leggiamo lì. Non è una ragione per saltare il controllo incrociato.
  • subscriptions/listen, e i Multi Round-Trip Requests con InputRequiredResult. Funzionalità intere più che correzioni. Non abbiamo mai implementato resources/subscribe, quindi il metodo che lo sostituisce oggi non ci costa niente.
  • La validazione dell'header Origin. La specifica la marca come MUST, con 403 su un origin non valido, come difesa contro il DNS rebinding. Su /mcp non la facciamo.
  • extensions sulle capability, e ordinamento deterministico da tools/list. Il secondo è uno SHOULD, pensato per il caching lato client e per il tasso di hit della prompt cache degli LLM. I nostri escono da una map di Go, quindi l'ordine è quello che quella map ci dà quel giorno.
  • Dynamic Client Registration. Questa revisione la deprecia a favore dei Client ID Metadata Documents, e noi esponiamo ancora POST /register. Resta disponibile per gli authorization server che non supportano il sostituto, quindi questa è una migrazione più che una rottura, sullo stesso orologio di dodici mesi di Roots, Sampling e Logging.
  • server/discover sta dietro la stessa API key di tutto il resto su /mcp. Non risponderà a un chiamante anonimo, il che è una scelta deliberata e non quello che si aspetta un client che sta scoprendo un server.

Il resto è lavoro, ed è sulla lista invece che fatto.

Se gestisci il tuo server MCP

I controlli che vale la pena fare sul tuo:

  1. Manda tools/list con MCP-Protocol-Version: 2026-07-28 e senza handshake. Se ottieni "session not initialized", il tuo server non sta servendo la revisione corrente.
  2. Chiama server/discover. Adesso è obbligatorio. Se restituisce method-not-found, quello è il buco più piccolo da chiudere.
  3. Dichiara una versione che non supporti. Verifica che l'errore porti la lista di quelle che supporti, e che il suo codice sia -32022 invece di uno generico. Questo è il controllo che abbiamo fallito.
  4. Leggi un result qualsiasi. Ognuno di loro ha bisogno di resultType, e i tuoi risultati di listing hanno bisogno anche di ttlMs e cacheScope.
  5. Guarda cosa restituisci in Mcp-Session-Id su una request stateless. Vuoto è peggio che assente.
  6. Conta le tue scritture. Manda dieci request stateless e verifica se è atterrato qualcosa nel tuo store delle sessioni. Quel numero è la risposta onesta alla domanda se la migrazione ha funzionato.
  7. Se servi ancora HTTP+SSE e fai girare più di un'istanza, fai POST su una mentre lo stream è tenuto da un'altra. Un client che si blocca senza niente nei log è il bug che avevamo. Poi leggi la riga del registro delle deprecazioni qui sopra.

Il divario tra "accetta il nuovo header di versione" e "davvero stateless" è dove sta la maggior parte del lavoro, e solo il passo 6 ti dice da che parte sei.

Collegalo

L'endpoint è invariato, ed entrambe le revisioni funzionano contro di esso. Per Claude Code, un .mcp.json nella root del tuo progetto:

{
  "mcpServers": {
    "archyl": {
      "type": "http",
      "url": "https://api.archyl.com/mcp",
      "headers": {
        "X-API-Key": "YOUR_API_KEY"
      }
    }
  }
}

È il tuo client a scegliere la revisione. Se parla 2026-07-28, viene servito senza handshake e senza sessione. Se non la parla, per lui non cambia niente.

Il setup completo per Claude Code, Cursor, VS Code, Codex, Warp, Windsurf e Antigravity, più gli scope che decidono cosa un agente può modificare, è nella documentazione del server MCP.