O MCP Ficou Stateless: O Que a Revisão 2026-07-28 Tirou do Nosso Servidor
Se você opera um servidor MCP, você tem uma sessão em algum lugar. Provavelmente uma tabela, talvez um map em memória. Um cliente conecta, envia initialize, recebe de volta um Mcp-Session-Id, e carrega esse header em toda requisição dali em diante. Você guarda a linha. Você a expira depois de um tempo. Você garante que uma requisição caia na instância dona dela, ou compartilha o estado entre instâncias.
A revisão 2026-07-28 apagou isso. Não marcou como deprecated: removeu do núcleo do protocolo. O handshake se foi, o header de sessão se foi, e cada requisição agora carrega a própria versão de protocolo e a própria identidade de cliente. Como coloca o post de release, "any request can now land on any server instance behind a plain round-robin load balancer without needing shared storage" — qualquer requisição pode agora cair em qualquer instância do servidor atrás de um load balancer round-robin comum, sem precisar de armazenamento compartilhado.
O servidor MCP do Archyl já atende os clientes que falam a nova revisão. Este post é o que isso exigiu, o que medimos depois, a única coisa que erramos na primeira passada, e o que não fizemos. Se você mantém um servidor MCP, as partes interessantes são provavelmente a decisão de design no meio, o bug que auditar o transporte novo revelou no antigo, e o checklist no final para ir olhar o seu.
O que a revisão realmente removeu
Direto do changelog, as partes que tocam uma implementação de servidor:
- As sessões em nível de protocolo e o header
Mcp-Session-Idforam removidos do transporte Streamable HTTP. Endpoints de listagem não variam mais por conexão. - O handshake
initialize/notifications/initializedfoi removido. Cada requisição carrega sua versão de protocolo e suas capabilities de cliente em_meta, e no Streamable HTTP a mesma versão viaja no headerMCP-Protocol-Version. server/discoveré novo e obrigatório. Servidores DEVEM implementá-lo, para anunciar versões de protocolo suportadas, capabilities e identidade. Clientes PODEM chamá-lo antes de qualquer outra coisa; eles também são livres para mandar uma requisição e tratar um erro de versão.ping,logging/setLevelenotifications/roots/list_changedforam removidos.- Incompatibilidades de versão retornam
UnsupportedProtocolVersionError, listando as versões que o servidor de fato suporta para que o cliente possa tentar de novo.
Tem mais coisa ali (Multi Round-Trip Requests, subscriptions/listen, resultados de listagem cacheáveis, um bloco renumerado de códigos de erro, endurecimento da authorization), e eu volto a quais delas fizemos e quais pulamos. As cinco acima são as que mudam a forma de um servidor, não as funcionalidades dele.
Um ponto que vale ser preciso, porque muda a decisão: isso não é mais um release candidate. O release candidate foi congelado em 21 de maio de 2026 e abriu uma janela de validação de dez semanas para mantenedores de SDK e implementadores de cliente. Essa janela fechou em 28 de julho de 2026, quando a especificação saiu, e a página de versionamento agora chama 2026-07-28 de "the current protocol version" — a versão atual do protocolo. Os quatro SDKs Tier 1 (TypeScript, Python, Go, C#) a falam desde o dia do lançamento, com Rust em beta. Se você estava esperando o RC assentar, ele assentou.
O que isso significou para um servidor com 181 tools
O servidor MCP do Archyl expõe 181 tools sobre o modelo C4: projetos, sistemas, containers, components, relationships, ADRs, docs, contracts, conformance, drift, DORA, ownership. Antes dessa mudança, todos os 181 ficavam atrás de uma sessão.
Concretamente, no nosso backend:
- Cada conexão criava uma linha numa tabela
mcp_sessions, com expiração de 24 horas e uma goroutine em segundo plano varrendo linhas velhas e expiradas. - Os canais de resposta SSE viviam num
map[string]chan *JSONRPCMessageno struct do servidor, indexado por session ID, o que prendia uma conexão ao processo que a abriu. Esse map já se mudou desde então, e o motivo acabou sendo um bug, não uma preferência. Volto a isso mais abaixo. - Quatro handlers (
tools/list,tools/call,resources/list,resources/read) abriam com as mesmas três linhas:
if !session.Initialized {
return s.errorResponse(msg.ID, ErrCodeInvalidRequest, "Session not initialized", nil)
}
Esse guard é o interessante. Ele faz uma pergunta que o novo protocolo tornou irrespondível: este chamador completou o handshake? Não existe handshake para completar.
A decisão que manteve a mudança pequena
O movimento tentador é ensinar statelessness a esses quatro handlers. Adicionar uma segunda condição, ou um session.Stateless || na frente de cada checagem, ou puxar tudo para um middleware.
Não fizemos nada disso. Os guards estão intocados. Em vez disso, uma requisição que declara 2026-07-28 recebe uma sessão em memória construída para aquela única requisição, que satisfaz o guard por construção:
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,
}
}
Nada é persistido. Nenhum ID é alocado. Nenhum canal SSE é registrado. Initialized: true não é mentira nem bypass: sob esta revisão a requisição está genuinamente inicializada, porque o protocolo carrega a própria versão e o handshake que ela teria completado não existe mais.
Por que esse enquadramento importa mais do que parece: esses quatro guards ficam num caminho adjacente à autorização. Cada um deles é a diferença entre uma chamada de tool rodar e ser recusada. Editar quatro pontos de chamada que respondem todos a uma pergunta com formato de segurança são quatro chances de enfraquecer uma checagem, espalhadas por um diff que quem revisa tem que segurar inteiro na cabeça de uma vez. Construir o objeto que os guards já esperam é uma função nova, e cada checagem existente mantém exatamente o mesmo significado.
Também falha numa direção segura. Se nossa detecção de versão errar e uma requisição stateless for lida como legada, a consequência é que uma linha de sessão é criada para ela. Nada passa que não passaria antes. O design inverso, afrouxar os guards e condicioná-los a uma string de versão, falha para o outro lado.
A statelessness também não nos custou nada em tenancy, porque a identidade nunca veio da linha de sessão para começo de conversa. A sessão stateless carrega o usuário e a organização resolvidos a partir da API key ou do token OAuth apresentado naquela requisição, os scopes são rederivados a cada chamada, então revogar uma key tem efeito imediato, e tools/call continua recusando uma sessão sem tenant vinculado. Agora tem uma coisa a menos para roubar: nenhum session ID armazenado que possa ser reproduzido. No caminho legado mantivemos a checagem correspondente, então um session ID não pode fazer uma credencial agir com a identidade guardada na sessão de outra pessoa.
O roteamento, num switch
A decisão inteira vive no handler HTTP, antes mesmo de o body JSON-RPC ser parseado:
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.
}
Dois detalhes ali que passam batido com facilidade:
IsModernProtocolVersion é uma comparação de strings contra "2026-07-28". Revisões são YYYY-MM-DD, então ordem lexical é ordem cronológica, e uma revisão futura cai no lado stateless por padrão em vez de voltar para o handshake. Ela só chega até aí se a suportarmos: uma versão não reconhecida é rejeitada antes do switch, com a lista das suportadas nos dados do erro para o cliente poder tentar de novo.
E o header de resposta:
if !session.Stateless {
c.Set("Mcp-Session-Id", session.ID)
}
Uma sessão stateless não tem ID. Devolver um Mcp-Session-Id vazio diria a um cliente para reutilizar algo que não existe, o que é um bug pior do que não mandar nada, e do tipo que só aparece contra um cliente que você não escreveu.
O resto do changelog, lido direito
O switch de versão é a decisão interessante. O resto da revisão é uma lista de exigências pequenas, fáceis de perder e baratas de verificar, então voltamos ao changelog linha por linha. Quatro delas entraram nessa passada.
resultType em todo result. A revisão torna o campo obrigatório: "complete" para uma resposta concluída, "input_required" para o resultado intermediário no padrão multi round-trip. Os clientes são orientados a tratar a ausência dele num servidor mais antigo como "complete", mas um cliente que lê a revisão final procura por ele. Os nossos agora carregam, vindo de um struct Result embutido em cada tipo de resultado em vez de cada tipo lembrar do campo por conta própria.
ttlMs e cacheScope em resultados de listagem. Obrigatórios em tools/list, prompts/list, resources/list, resources/read e resources/templates/list, através de uma nova interface CacheableResult. Nós hospedamos três desses cinco, e eles retornam 60000 e private. Sessenta segundos é uma dica, não um contrato: longo o bastante para impedir que um agente liste os 181 tools de novo a cada turno, curto o bastante para que um tool registrado no meio da sessão apareça rápido. private é uma decisão, não um default que aceitamos. Todo resultado que devolvemos está limitado à organização de quem chamou, então nenhum intermediário compartilhado pode cachear um deles e entregar a outro tenant.
DELETE /mcp de um cliente que declara 2026-07-28. O DELETE encerrava uma sessão em nível de protocolo, e não existem mais sessões em nível de protocolo. A especificação diz para responder 405, então é isso que um cliente moderno recebe. Um cliente baseado em handshake mantém o comportamento antigo.
Um método não implementado agora retorna HTTP 404 carregando JSON-RPC -32601. O status code sozinho é ambíguo: um servidor legado HTTP+SSE que nem hospeda o endpoint moderno também responde 404. É o body JSON-RPC que separa os dois, e a especificação é explícita: o cliente usa isso para decidir se cai de volta para initialize ou se tenta de novo.
E uma que erramos na primeira passada
Nosso erro de versão não suportada retornava -32600, o "invalid request" genérico do JSON-RPC. Isso foi defensável até exatamente esta revisão, que define uma política de alocação de códigos de erro dividindo a faixa de erros de servidor do JSON-RPC: de -32000 a -32019 continua definido pela implementação, de -32020 a -32099 pertence à especificação. Os códigos introduzidos durante o draft foram renumerados para dentro desse bloco. HeaderMismatch foi de -32001 → -32020, MissingRequiredClientCapability de -32003 → -32021, e UnsupportedProtocolVersion de -32004 → -32022.
Um cliente escrito contra a revisão final procura por -32022. Ele não teria reconhecido o que estávamos mandando, e o modo de falha é exatamente aquele que esta revisão inteira foi desenhada para evitar: o cliente não consegue distinguir "versão errada, aqui estão as que eu falo" de "sua requisição estava malformada", então não tem nada com que tentar de novo.
Nada pegou isso além de ler o changelog uma segunda vez, que é o próprio argumento deste post apontado de volta para nós. A renumeração é o item 12 das mudanças menores, depois das entradas sobre chaves _meta do OpenTelemetry e palavras-chave de JSON Schema. É o tipo de linha que a gente passa os olhos por cima.
Um rename não nos custou nada. Resource-not-found saiu de -32002 para -32602, para se alinhar ao "invalid params" do JSON-RPC, e resources/read já respondia -32602 para uma URI desconhecida.
O que medimos
Tudo isso foi medido contra um container rodando nesta build, com uma API key real, para que pudéssemos contar as linhas no Postgres diretamente.
| Teste | Resultado |
|---|---|
tools/list com MCP-Protocol-Version: 2026-07-28, sem handshake |
181 tools |
Mcp-Session-Id devolvido nessa resposta |
nenhum |
resultType em tools/list e em server/discover |
complete |
ttlMs / cacheScope em tools/list |
60000 / private |
server/discover |
["2026-07-28", "2025-03-26"] |
| Versão não suportada declarada | -32022, lista das suportadas nos dados do erro |
DELETE /mcp de um cliente que declara 2026-07-28 |
405 |
| Método desconhecido | 404 carregando -32601 |
Handshake initialize legado |
continua funcionando |
tools/list legado com um session id |
181 tools |
Linhas de mcp_sessions criadas por 10 requisições stateless |
0 |
Linhas de mcp_sessions criadas por 3 requisições legadas |
3 |
O último par é o que interessa olhar. Dez requisições, nenhuma linha. As três requisições legadas chegaram cada uma sem session ID, então cada uma criou a sua; um cliente bem-comportado baseado em handshake, que reaproveita seu ID, ganha uma linha para a vida inteira da sessão, não uma por chamada. O ponto é o zero: no caminho stateless não há nada a escrever, nada a expirar, e nada para a goroutine de limpeza encontrar.
A requisição que produziu a primeira linha daquela tabela, apontada para o endpoint público:
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"}'
Sem initialize. Sem sessão. 181 tools.
O bug que o transporte deprecated estava escondendo
Auditar o transporte novo nos fez olhar para o antigo, e o antigo tinha um bug de verdade.
O transporte HTTP+SSE de 2024-11-05 divide uma conversa em duas conexões. O cliente abre um stream de vida longa com GET, o primeiro evento do servidor diz a ele para onde fazer POST, e a partir daí toda mensagem sai por um POST enquanto toda resposta volta pelo stream. Essas duas conexões não precisam cair na mesma instância.
A nossa presumia que sim. Os canais de resposta viviam naquele map[string]chan *JSONRPCMessage no struct do servidor, então um POST atendido pela instância B escrevia sua resposta num canal que existia na instância B e que ninguém na instância B estava lendo. O stream estava na instância A. O cliente esperava.
O que torna isso pior do que um cheiro de design é que nada era logado. Nenhum erro, nenhum aviso, nenhuma requisição falha. O POST retornava 202 Accepted, o que era verdade, a mensagem tinha sido aceita, e a resposta ia para lugar nenhum. De fora é indistinguível de uma chamada de tool lenta. Só acontece num deploy escalado horizontalmente, que é exatamente onde você tem menos vontade de reproduzir algo na mão.
O map agora é um roteador Redis pub/sub em streamrouter.go. Uma resposta para um stream que este processo está segurando é entregue diretamente e nunca faz a ida e volta. Uma resposta para um stream segurado em outro lugar é publicada em mcp:stream:<sessionID>, e a instância que segura esse stream está inscrita nele. Qualquer instância pode pegar o POST. Nenhuma afinidade de sessão é necessária, e nenhuma regra de sticky session precisa ser mantida numa config de load balancer que ninguém lembra de ter escrito.
Duas coisas valem ser ditas sobre isso, porque um roteador é uma dependência.
O Redis agora está nesse caminho. Se estiver inalcançável no boot, o roteador cai para entrega apenas local e loga um aviso em vez de se recusar a subir, porque só-local é correto para uma instância única e só passa a ser errado quando existe uma segunda. A falha é barulhenta de propósito: a alternativa é o travamento silencioso que acabamos de remover. Se você fizer deploy disso, a linha de startup a procurar é MCP stream router: Redis connected. A ausência dela é a história inteira.
E o roteador conserta roteamento, não localização. O stream continua sendo uma conexão segurada por um processo; o Redis leva as respostas até esse processo, ele não move o stream. Essa parte é irredutível. Uma conexão aberta vive onde foi aberta, em qualquer protocolo.
O que não fizemos
É aqui que um anúncio normalmente para. Duas coisas valem ser ditas sem rodeios, porque você pode conferir as duas.
O Archyl fala 2026-07-28 no caminho que importa. Ele não é stateless de ponta a ponta.
O caminho stateless é genuinamente stateless: nenhum lookup de sessão, nenhuma escrita de sessão, nenhum Mcp-Session-Id, nada prendendo uma requisição a um processo. Esse caminho pode ficar atrás de um load balancer round-robin comum.
Nosso servidor também continua respondendo o transporte HTTP+SSE mais antigo em /sse, mas paramos de documentá-lo. Toda página que antes imprimia aquela URL agora imprime /mcp, e esse é o único endpoint que pedimos para alguém configurar.
O motivo é a dependência que acabamos de adicionar. O roteador remove a exigência de afinidade só onde o Redis está alcançável. Onde não está, a entrega cai para apenas local, o que é correto com uma instância e silenciosamente errado com duas. Nossa própria produção não roda Redis hoje, então o fallback é o que estamos rodando. Preferimos apontar todo mundo para o transporte cuja correção não depende de uma contagem de instâncias a publicar um cuja correção depende.
O que continua verdade sobre /sse onde quer que ele rode: o stream é uma conexão segurada por um único processo, e existe uma linha de sessão no Postgres enquanto ele durar. Remover a exigência de afinidade não é o mesmo que remover o estado. Não estamos anunciando uma data para aposentar esse transporte.
O relógio desse transporte não é nosso, porém, e é mais curto do que supúnhamos. O HTTP+SSE está deprecated desde a revisão 2025-03-26; o que a 2026-07-28 fez foi reclassificá-lo como Deprecated sob a nova política de ciclo de vida de funcionalidades. Essa política estabelece uma janela mínima de doze meses entre a depreciação e a elegibilidade para remoção, que é o que Roots, Sampling e Logging ganham: remoção mais cedo em "the first revision released on or after 2027-07-28" — a primeira revisão lançada em 28 de julho de 2027 ou depois. O HTTP+SSE não ganha doze meses, porque já estava deprecated muito antes de a política existir. O registro de funcionalidades deprecated lista sua remoção mais cedo como "Three months after SEP-2596 reaches Final" — três meses depois de a SEP-2596 chegar a Final. A remoção ainda é uma decisão dos Core Maintainers tomada na preparação do release e pode acontecer depois, mas se você roda HTTP+SSE em algum lugar, essa é a linha para ler.
Implementamos a forma da revisão, não ela toda. O que sai é negociação de versão, o caminho de requisição stateless, server/discover, o erro de versão não suportada com o código certo, resultType, as dicas de cache, e o 405 e o 404 que o transporte pede, ao lado do caminho de handshake para os clientes que ainda precisam dele. Aqui está o que não tem:
- Os headers de requisição
Mcp-MethodeMcp-Name, e a validação que vem junto. Esta é a maior lacuna. A revisão exige que um POST espelhe seumethod, e seuparams.nameouparams.uri, em headers, e exige que o servidor rejeite qualquer divergência com400e-32020 HeaderMismatch. O motivo não é organização. Nas palavras da própria especificação, isso "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)" — previne potenciais vulnerabilidades de segurança quando componentes diferentes da rede se apoiam em fontes de verdade diferentes (por exemplo, um load balancer roteando pelo valor do header enquanto o servidor MCP executa com base no valor do body). A mesma regra cobreMCP-Protocol-Version, cujo valor DEVE bater com o do_metada requisição. Nós lemos a versão apenas do header e nunca olhamos o_meta, então não conseguimos detectar uma divergência que somos obrigados a rejeitar. O header está disponível antes de o body ser parseado, e é por isso que o lemos ali. Isso não é motivo para pular a verificação cruzada. subscriptions/listen, e Multi Round-Trip Requests comInputRequiredResult. Funcionalidades inteiras, mais do que correções. Nunca implementamosresources/subscribe, então o método que o substitui não nos custa nada hoje.- Validação do header
Origin. A especificação marca como MUST, com403num origin inválido, como defesa contra DNS rebinding. Não fazemos isso em/mcp. extensionsnas capabilities, e ordenação determinística vinda detools/list. A segunda é um SHOULD, voltada para cache do lado do cliente e para a taxa de acerto do prompt cache dos LLMs. Os nossos saem de um map de Go, então a ordem é a que aquele map nos der naquele dia.- Dynamic Client Registration. Esta revisão a deprecia em favor dos Client ID Metadata Documents, e nós ainda expomos
POST /register. Continua disponível para authorization servers que não suportam o substituto, então isto é uma migração e não uma quebra, no mesmo relógio de doze meses de Roots, Sampling e Logging. server/discoverfica atrás da mesma API key que todo o resto em/mcp. Ele não responde a um chamador anônimo, o que é uma escolha deliberada e não é o que um cliente descobrindo um servidor espera.
O resto é trabalho, e está na lista em vez de estar feito.
Se você opera seu próprio servidor MCP
As checagens que valem a pena rodar contra o seu:
- Envie
tools/listcomMCP-Protocol-Version: 2026-07-28e sem handshake. Se você receber "session not initialized", seu servidor não está servindo a revisão atual. - Chame
server/discover. Agora é obrigatório. Se retornar method-not-found, essa é a menor lacuna a fechar. - Declare uma versão que você não suporta. Confira se o erro carrega a lista das que você suporta, e se o código dele é
-32022em vez de um genérico. Esta é a checagem que nós falhamos. - Leia qualquer result. Todos eles precisam de
resultType, e seus resultados de listagem precisam dettlMsecacheScopepor cima disso. - Olhe o que você devolve em
Mcp-Session-Idnuma requisição stateless. Vazio é pior que ausente. - Conte suas escritas. Envie dez requisições stateless e veja se algo caiu no seu armazenamento de sessões. Esse número é a resposta honesta sobre se a migração funcionou.
- Se você ainda serve HTTP+SSE e roda mais de uma instância, faça POST numa enquanto o stream está sendo segurado por outra. Um cliente que trava sem nada nos logs é o bug que tínhamos. Depois leia a linha do registro de depreciação lá de cima.
A distância entre "aceita o novo header de versão" e "realmente stateless" é onde está a maior parte do trabalho, e só o passo 6 te diz de que lado você está.
Conecte
O endpoint não mudou, e as duas revisões funcionam contra ele. Para o Claude Code, um .mcp.json na raiz do seu projeto:
{
"mcpServers": {
"archyl": {
"type": "http",
"url": "https://api.archyl.com/mcp",
"headers": {
"X-API-Key": "YOUR_API_KEY"
}
}
}
}
Seu cliente escolhe a revisão. Se ele fala 2026-07-28, é atendido sem handshake e sem sessão. Se não fala, nada muda para ele.
A configuração completa para Claude Code, Cursor, VS Code, Codex, Warp, Windsurf e Antigravity, mais os scopes que decidem o que um agente pode mudar, está na documentação do servidor MCP.