MCP stateless não elimina estado: sua aplicação precisa torná-lo explícito
Imagine um servidor MCP que controla navegadores remotos. Uma ferramenta abre o navegador, outra acessa uma URL e a terceira tira uma captura de tela.
No primeiro desenho da aplicação, o servidor guarda o navegador numa sessão em memória. A chamada seguinte chega à mesma instância, encontra o recurso e continua o trabalho. Tudo parece simples até o serviço ganhar uma segunda instância, um balanceador ou um retry.
Com o ciclo stateless da especificação MCP 2026-07-28, a próxima requisição pode cair em qualquer instância. Se ela não trouxer um browser_id, a identidade de quem fez a chamada e a operação esperada, o servidor não tem como adivinhar qual navegador deve recuperar.
É aí que a palavra "stateless" costuma confundir.
O protocolo deixa de manter uma sessão implícita. O navegador, o carrinho, o job de vídeo e a tarefa longa continuam existindo. Esse estado não desaparece. Ele precisa ganhar nome, contrato e regras de acesso.
O que realmente sai do protocolo
A nova revisão remove o handshake initialize/initialized, o header Mcp-Session-Id e a sessão mantida no nível do protocolo. Versão, identificação e capacidades do cliente passam a acompanhar cada requisição em _meta.
Na prática, isso reduz a afinidade entre uma conexão e uma instância específica do servidor. Uma chamada não precisa encontrar a mesma memória de processo usada pela chamada anterior. Fica mais simples distribuir tráfego, trocar uma instância com problema e operar o serviço sem sticky session.
Mas essa simplificação vale para o transporte. Ela não decide onde seu produto guarda recursos que vivem por mais de uma chamada.
Vale separar três coisas que antes podiam acabar misturadas:
- o contexto do protocolo, como versão e capacidades do cliente;
- o estado do produto, como um navegador aberto ou um carrinho em edição;
- o progresso de uma operação, como uma exportação que espera aprovação ou mais dados.
O primeiro passa em _meta. Os outros dois continuam sendo responsabilidade da aplicação.
Um handle explícito muda o contrato
Vamos voltar ao navegador remoto. Um fluxo mínimo pode funcionar assim:
create_browser() -> { browser_id: "brw_7f2..." }
navigate({
browser_id: "brw_7f2...",
url: "https://example.com"
})
create_browser persiste o recurso e devolve um handle opaco. navigate recebe esse handle, valida o acesso e recupera o navegador no armazenamento adequado. A instância que criou o recurso não precisa ser a mesma que executa a navegação.
Essa mudança melhora a rastreabilidade. Logs, métricas e erros podem apontar para o recurso envolvido sem depender de uma sessão escondida. Também deixa mais claro quais ferramentas criam estado e quais apenas consultam ou alteram algo já existente.
Só que um identificador enviado pelo cliente não é uma autorização.
Se o servidor aceita qualquer browser_id válido sem conferir o dono ou o escopo, ele cria uma porta para um usuário operar o recurso de outro. O contrato precisa responder a perguntas bem menos empolgantes do que "qual será o nome da ferramenta?", mas muito mais importantes:
- quem pode usar este handle;
- por quanto tempo ele existe;
- o que acontece depois da expiração;
- se ele pode atravessar organizações ou projetos;
- quais operações são permitidas para aquela identidade.
O ideal é que o handle não revele detalhes internos e que toda operação verifique a autorização no recurso recuperado. Não conte com o modelo para respeitar esse limite. O controle precisa existir no servidor.
O estado escondido costuma aparecer em cinco lugares
Antes de atualizar o SDK, procure onde a implementação atual depende da sessão. Uma busca por initialize, Mcp-Session-Id, Redis, caches em memória e configuração de sticky routing costuma revelar parte do problema.
Depois, acompanhe um fluxo real por duas ou três chamadas. O que a segunda chamada espera encontrar sem receber como argumento?
Em servidores pequenos, o estado escondido aparece bastante nestes pontos:
- recursos abertos, como navegador, terminal, arquivo temporário ou conexão;
- projeto, workspace, conta ou ambiente selecionado;
- permissões calculadas apenas durante a inicialização;
- resultados parciais de tarefas demoradas;
- chaves usadas para correlacionar logs, cobranças ou limites.
Nem tudo precisa virar um campo enorme repetido em cada chamada. Muitas vezes basta passar um identificador curto e recuperar os dados no servidor. O ponto é tornar a dependência visível no contrato, em vez de deixá-la presa à memória de uma conexão.
Também vale resistir à tentação de criar um context_id genérico que guarda tudo. Ele pode recriar a antiga sessão com outro nome. Prefira handles ligados a recursos e operações concretas: browser_id, cart_id, export_id, task_id.
Retry sem idempotência duplica o problema
Uma arquitetura stateless facilita repetir uma chamada quando a conexão cai ou uma instância some. Para leituras, isso tende a ser tranquilo. Para operações com efeito colateral, o retry pode abrir dois navegadores, criar duas cobranças ou iniciar duas exportações.
Considere este fluxo:
create_browser({
idempotency_key: "run_42:create_browser"
})
Se a resposta se perder e o cliente repetir a chamada com a mesma chave, o servidor devolve o recurso já criado em vez de abrir outro. A forma exata depende do domínio, mas a pergunta é sempre a mesma: "Se esta requisição chegar duas vezes, o efeito também acontece duas vezes?"
Não jogue essa decisão para o cliente por acidente. Defina quais operações são seguras para retry, quais exigem uma chave e por quanto tempo o resultado dessa chave fica armazenado.
Para operações que aguardam nova entrada, o novo ciclo também pede um desenho explícito. Requisições iniciadas pelo servidor só podem acontecer durante uma chamada ativa. Quando faltar informação, o fluxo pode retornar um InputRequiredResult; o cliente responde depois em uma nova requisição. A retomada precisa carregar dados suficientes para localizar a operação anterior.
O estado continua lá. O que muda é a forma de retomá-lo.
Compatibilidade do SDK não migra seu produto
O GitHub MCP Server é um bom exemplo do ganho operacional. Na adaptação para a nova especificação, o servidor removeu gravações no Redis durante initialize e leituras de sessão em cada chamada. Informações usadas em logs e detecção de segredos passaram a vir dos novos headers, sem precisar inspecionar profundamente o payload.
O SDK Go também mantém uma camada de compatibilidade para o fluxo de elicitation funcionar com clientes antigos e novos. Isso reduz o trabalho de quem usa o SDK.
O cuidado está em transformar esse caso em regra universal. Um wrapper consegue resolver detalhes de compatibilidade do protocolo. Ele não sabe que seu current_workspace vivia na sessão, que uma ferramenta espera um token calculado na inicialização ou que um job precisa retomar do último checkpoint.
Atualizar a dependência é o começo da migração, não a prova de que ela terminou.
Se o servidor ainda aceita clientes anteriores, escolha o ciclo de vida pela versão negociada. Versões até 2025-11-25 usam o handshake; a revisão 2026-07-28 usa os metadados por requisição. Tentar empurrar os dois comportamentos por um único caminho implícito deixa a compatibilidade difícil de observar e ainda pior de remover depois.
Teste o protocolo e o seu domínio separadamente
Uma conexão bem-sucedida não prova que a migração está correta. Ela só mostra que cliente e servidor conseguiram conversar naquele cenário.
O framework oficial de conformidade permite testar clientes e servidores de forma separada. A suíte draft, configurada para --spec-version 2026-07-28, ajuda a verificar o ciclo stateless e a distinguir regressões novas de falhas já conhecidas.
Essa é a primeira camada. A segunda pertence ao produto.
Para o exemplo do navegador, eu testaria pelo menos:
| Cenário | Resultado esperado |
|---|---|
outra instância recebe navigate | recupera o navegador pelo browser_id |
| usuário informa handle de outra conta | acesso negado sem revelar dados do recurso |
| handle expirou | erro claro e recuperável |
create_browser é repetido com a mesma chave | nenhum navegador extra é criado |
| cliente antigo inicia uma sessão | caminho compatível é selecionado pela versão |
| operação pede mais dados | retomada localiza a mesma operação |
A conformidade cobre o protocolo. Esses testes cobrem autorização, expiração, idempotência e continuidade, que são regras da sua aplicação.
Um mapa de migração que cabe num PR
Se eu tivesse de preparar um servidor MCP próprio para essa revisão, dividiria o trabalho em passos pequenos:
- mapear dependências de
initialize, sessão, memória local e sticky routing; - listar recursos e operações que atravessam chamadas;
- criar handles opacos para os recursos que precisam ser retomados;
- validar identidade e autorização em toda operação que recebe um handle;
- definir idempotência para escritas e criações;
- separar o comportamento por versão enquanto houver clientes antigos;
- rodar a suíte de conformidade;
- adicionar testes de domínio para retry, expiração, acesso indevido e troca de instância.
O PR não precisa redesenhar todo o produto de uma vez. Comece pelo fluxo que mais depende da sessão e prove que outra instância consegue continuá-lo apenas com a requisição e os recursos referenciados.
Se isso ainda não for possível, você encontrou estado escondido. E esse é justamente o trabalho que a mudança stateless coloca à vista.
O teste que eu faria antes do merge
Remover a sessão do núcleo do MCP pode simplificar bastante a infraestrutura. O servidor ganha liberdade para rotear chamadas e trocar instâncias sem depender da memória de uma conexão específica.
Em compensação, a aplicação precisa assumir o trabalho que a sessão fazia silenciosamente. Os handles entram nos contratos, cada recurso exige verificação de acesso e as novas tentativas precisam ser idempotentes. Uma operação interrompida também precisa dizer como será retomada.
Antes de considerar a migração pronta, faça um teste simples: crie um recurso numa instância e envie a próxima chamada para outra. Se ela consegue continuar usando apenas a nova requisição, a identidade autorizada e os recursos referenciados, o estado está explícito o bastante para ser operado.
Se não consegue, o problema não é que o MCP perdeu contexto. É que sua aplicação ainda guardava contexto num lugar que o contrato não mostrava.