Pular para conteúdo

Atendendo clientes legados

Tradução automática

Esta página foi traduzida automaticamente a partir da documentação em inglês, e a página em inglês é a versão de referência. Se algo parecer errado, Traduções explica como avisar.

O MCP tem duas eras de protocolo: a era do handshake initialize, até a versão da especificação 2025-11-25, e a era moderna, 2026-07-28. Versões do protocolo é a página sobre a divisão em si.

Esta página trata do lado do servidor dessa divisão, e a resposta cabe em uma frase: o streamable_http_app() que você já faz o deploy atende as duas.

O SDK roteia cada requisição pelo header MCP-Protocol-Version. Uma requisição que indica 2026-07-28 vai para o handler moderno. Uma requisição que indica uma versão da era do handshake, ou que não traz header nenhum (que é como o initialize de um cliente pré-2026 chega), vai para o transporte que esses clientes esperam: handshake initialize, sessões e tudo mais. Isso acontece por requisição, antes do seu código, no mesmo app.

Então um cliente legado não é algo para o qual você constrói. É algo que se conecta ao servidor que você já escreveu. Você não configura nada.

Note

Nada, literalmente. Não existe opção legacy=, nem allowlist de versões, nem forma de rejeitar ou desabilitar uma era: nem em streamable_http_app(), nem em run(), nem no gerenciador de sessões. As duas eras estão sempre ativas. O mais próximo de uma chave por era nessa assinatura é stateless_http, e ele é a maior parte desta página.

Um handler, as duas eras

Aqui está uma ferramenta (tool) que precisa perguntar algo ao usuário:

server.py
from typing import Annotated

from pydantic import BaseModel

from mcp.server import MCPServer
from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve

mcp = MCPServer("Bookshop")


class Quantity(BaseModel):
    copies: int


async def ask_quantity() -> Elicit[Quantity]:
    """Resolver: ask the user how many copies to put aside."""
    return Elicit("How many copies?", Quantity)


@mcp.tool()
async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]) -> str:
    """Reserve copies of a book, asking the user how many."""
    if isinstance(quantity, AcceptedElicitation):
        return f"Reserved {quantity.data.copies} of {title!r}."
    return "Nothing reserved."

reserve precisa de uma coisa que o modelo não forneceu: quantas cópias. Annotated[..., Resolve(ask_quantity)] é como uma ferramenta declara isso (Dependências tem essa história completa). Nada em reserve cita uma versão, verifica uma capacidade ou ramifica.

Sirva-a via HTTP, e aqui estão clientes das duas eras chamando-a:

uv run mcp run server.py --transport streamable-http
client.py
import anyio

from mcp import Client
from mcp.client import ClientRequestContext
from mcp.types import ElicitRequestParams, ElicitResult


async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
    return ElicitResult(action="accept", content={"copies": 2})


async def main() -> None:
    async with (
        Client("http://localhost:8000/mcp", mode="legacy", elicitation_callback=answer) as legacy,
        Client("http://localhost:8000/mcp", elicitation_callback=answer) as modern,
    ):
        for client in (legacy, modern):
            result = await client.call_tool("reserve", {"title": "Dune"})
            print(client.protocol_version, result.structured_content)


if __name__ == "__main__":
    anyio.run(main)

Os dois clientes ficam abertos ao mesmo tempo, contra o mesmo servidor em execução. mode="legacy" executa o handshake initialize: exatamente a conexão que um cliente pré-2026 abre. O outro usa o padrão e cai em 2026-07-28. Execute python client.py em um segundo terminal:

2025-11-25 {'result': "Reserved 2 of 'Dune'."}
2026-07-28 {'result': "Reserved 2 of 'Dune'."}

Mesmo servidor, mesmo handler, mesma resposta. A funcionalidade inteira é essa.

Vale parar no como, porque os dois clientes receberam a mesma pergunta por dois fios completamente diferentes. A conexão 2026-07-28 não tem canal para o servidor enviar uma requisição, então Resolve retornou a pergunta dentro do resultado da ferramenta e o cliente repetiu a chamada com a resposta (Requisições de múltiplas idas e voltas). A conexão 2025-11-25 não tem nada disso; ali, Resolve enviou uma requisição elicitation/create ao vivo no meio da chamada e esperou. Você não escreveu nenhum dos dois. Resolve lê a versão negociada da conexão e escolhe; o corpo da sua ferramenta vê um AcceptedElicitation de qualquer forma.

Tip

Essa portabilidade entre eras é o motivo de Resolve ser a API sobre a qual construir. Seu irmão mais velho, ctx.elicit() (Elicitação), só envia elicitation/create, então só funciona em uma conexão legada. Em uma 2026-07-28, a chamada falha. Se uma ferramenta ainda o usa, a correção é a que você vê acima, não uma verificação de versão.

Quanto uma sessão legada custa para você

O roteamento é grátis. A sessão não.

Uma conexão 2026-07-28 é sem sessão: cada requisição é independente, e o handler moderno nunca emite um Mcp-Session-Id. Uma conexão legada é o oposto. No momento em que um cliente pré-2026 envia initialize, o SDK gera um Mcp-Session-Id, retorna-o em um header de resposta e mantém um registro vivo por trás dele para as requisições posteriores do cliente encontrarem: a versão negociada, os streams abertos, uma task em segundo plano conduzindo a sessão.

Esse registro é um dict simples dentro do processo. Não existe armazenamento distribuído de sessões nem forma de plugar um.

Com um worker, isso é invisível. Com dois, é o problema inteiro: uma requisição que traz um Mcp-Session-Id e cai em um worker que não o gerou não encontra nada naquele dict, e a resposta é um 404 (Session not found), não o resultado da ferramenta. Então, no momento em que você executa mais de um worker, clientes legados precisam de roteamento sticky: toda requisição de uma sessão tem que chegar ao processo que a iniciou. Clientes modernos nunca precisam; eles não têm sessão à qual aderir. Deploy e escala cobre stickiness e tudo mais sobre executar mais de uma dessas instâncias.

Warning

event_store= parece a solução e não é. Ele é retomabilidade (reenviar eventos SSE perdidos para um cliente que se reconecta à mesma sessão), não um armazenamento de sessões. Ele nunca torna uma sessão alcançável a partir de outro processo.

Tempo de vida e limites de sessão

Uma sessão legada não vive para sempre, e um processo não mantém um número ilimitado delas. Duas configurações controlam isso. Ambas são argumentos nomeados em run(), streamable_http_app() e Server.streamable_http_app(). Conexões modernas (2026-07-28) e stateless_http=True não têm sessões, então nenhuma das duas configurações se aplica a elas.

Configuração Padrão O que faz O que o cliente vê Para desligar
session_idle_timeout 1800 (30 min) Fecha uma sessão que não teve nada em andamento por esse tempo. 404 Session not found. Ele precisa fazer initialize de novo. None
max_sessions 10_000 Recusa abrir uma sessão além dessa quantidade. As sessões existentes ficam intactas e nada é despejado. 503 Too many open sessions com o código JSON-RPC -32603. None

O que conta como "em andamento":

  • Um stream GET aberto. Os clientes do SDK mantêm um aberto, então a sessão de um cliente conectado nunca expira.
  • Uma requisição que ainda está sendo respondida. Uma chamada de ferramenta que roda por mais tempo que o timeout não é interrompida, e a contagem regressiva só começa quando ela termina.
  • Nada mais. Entre requisições, o relógio corre. Qualquer requisição na sessão o reinicia, ping inclusive. Depois que uma sessão expirou, nada a revive.

Um cliente que encerra sua sessão com DELETE a libera imediatamente. O mesmo vale para um cliente cuja requisição de abertura foi recusada.

mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000)

Os dois eventos aparecem no log do servidor. Uma expiração é Session <id> idle timeout em INFO. Uma abertura recusada é Refusing to open a new session: <n> sessions are already open em WARNING.

Os limites são por processo. Com quatro workers, o teto é quatro vezes max_sessions, e cada worker expira suas próprias sessões.

A única chave: stateless_http

Se stickiness é um custo que você se recusa a pagar, existe exatamente uma coisa que você pode mudar.

server.py
from typing import Annotated

from pydantic import BaseModel

from mcp.server import MCPServer
from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve

mcp = MCPServer("Bookshop")


class Quantity(BaseModel):
    copies: int


async def ask_quantity() -> Elicit[Quantity]:
    """Resolver: ask the user how many copies to put aside."""
    return Elicit("How many copies?", Quantity)


@mcp.tool()
async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]) -> str:
    """Reserve copies of a book, asking the user how many."""
    if isinstance(quantity, AcceptedElicitation):
        return f"Reserved {quantity.data.copies} of {title!r}."
    return "Nothing reserved."


app = mcp.streamable_http_app(stateless_http=True)

Esse é o servidor do topo da página mais uma keyword. stateless_http=True faz a perna legada construir uma sessão descartável por requisição: nenhum Mcp-Session-Id emitido, nada lembrado entre requisições, então qualquer worker pode atender qualquer requisição e o load balancer pode fazer o que quiser.

Duas coisas sobre ele importam mais do que o que ele faz.

Ele só afeta a perna legada. As requisições são roteadas pelo header de versão antes de stateless_http ser lido, então o caminho moderno nunca o vê. Uma conexão 2026-07-28 já é sem sessão e fica exatamente igual com qualquer um dos valores.

Ele custa os dois canais servidor-para-cliente nessa perna. Uma sessão que vive por um POST não tem stream para o servidor empurrar uma requisição nem stream independente para empurrar notificações. Toda requisição iniciada pelo servidor levanta NoBackChannelError: ctx.elicit(), as chamadas aposentadas de amostragem (sampling) e roots (Funcionalidades obsoletas) e, sim, Resolve fazendo sua pergunta a um cliente legado. As notificações nem recebem erro; são descartadas silenciosamente.

Note

json_response=True não é essa chave, mas cobra metade do mesmo custo em toda sessão legada: um POST respondido com um único corpo JSON não tem stream para o canal com escopo de requisição, então um ctx.elicit() no meio da requisição levanta o mesmo NoBackChannelError e as notificações ligadas à requisição são descartadas. O stream independente da sessão fica intacto: notificações não relacionadas continuam chegando.

Check

Faça a coisa errada. reserve é exatamente a ferramenta que acabou de atender os dois clientes. Faça o deploy dela com stateless_http=True, conecte os mesmos dois clientes e chame-a de cada um.

O cliente moderno ainda recebe Reserved 2 of 'Dune'. A perna moderna não mudou.

A chamada do cliente legado não volta como um resultado is_error que o modelo poderia ler. A requisição inteira falha, como um erro de protocolo de nível superior:

mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.

Resolve não salvou você. Em uma conexão 2025-11-25 ele tem que enviar elicitation/create, e o canal de que precisa é exatamente o que stateless_http=True abriu mão. Código portável entre eras não é código sem canal de retorno (back-channel).

Então é uma troca real, e ela só existe na perna legada: com sessão e sticky, ou sem estado e unidirecional. Se suas ferramentas nunca chamam o cliente de volta, stateless_http=True é grátis e você deve usá-lo. Se chamam, mantenha as sessões e mantenha o roteamento sticky.

Onde seu código realmente se bifurca

Quase em lugar nenhum.

Ferramentas, recursos, prompts, saída estruturada, progresso, erros: nenhum deles se importa com qual era chamou. O handshake initialize, o Mcp-Session-Id, o stream independente, o DELETE que encerra uma sessão: o SDK cuida de tudo isso, e um handler nunca vê nada disso. Entrada interativa é o lugar em que as eras genuinamente diferem no fio, e Resolve existe para que isso não seja problema seu: você acabou de ver uma ferramenta atender as duas.

Sobra exatamente uma coisa, e são as notificações de mudança, porque as duas eras escutam em canais diferentes:

  • Um cliente 2026-07-28 abre um stream subscriptions/listen e lê o barramento de assinaturas. ctx.notify_resource_updated() (e notify_tools_changed(), notify_prompts_changed(), notify_resources_changed()) publicam ali, e somente ali. Assinaturas é essa página.
  • Um cliente legado lê o stream independente que sua sessão mantém aberto. ctx.session.send_resource_updated() (e send_tool_list_changed() e companhia) escrevem na conexão que carregou a requisição: para uma sessão legada, esse é o stream independente dela. Uma conexão moderna não tem lugar para isso: via HTTP não existe tal canal, e via stdio os quatro tipos de notificação de mudança trafegam apenas em streams subscriptions/listen, então em uma conexão moderna a notificação é descartada silenciosamente.

Via HTTP, nenhuma das duas chamadas alcança os clientes da outra era. Para avisar todo mundo, chame as duas:

server.py
from mcp.server import MCPServer
from mcp.server.mcpserver import Context

mcp = MCPServer("Bookshop")

STOCK = {"Dune": 3}


@mcp.resource("stock://{title}")
def stock(title: str) -> str:
    """How many copies of one book are on the shelf."""
    return f"{STOCK[title]} in stock"


@mcp.tool()
async def restock(title: str, copies: int, ctx: Context) -> str:
    """Put copies of a book back on the shelf."""
    STOCK[title] = STOCK.get(title, 0) + copies
    await ctx.notify_resource_updated(f"stock://{title}")
    await ctx.session.send_resource_updated(f"stock://{title}")
    return f"{STOCK[title]} in stock"

Duas linhas, nenhum if, nenhuma verificação de versão, e pronto. Essa é a lista inteira de coisas que um handler faz diferente porque um cliente legado existe.

Recapitulando

  • Um único streamable_http_app() atende as duas eras de protocolo. O SDK roteia cada requisição pelo header MCP-Protocol-Version; não há nada para configurar nem chave de era para procurar.
  • Um cliente legado custa uma sessão: um registro Mcp-Session-Id dentro do processo, sem armazenamento distribuído por trás. Mais de um worker significa roteamento sticky, ou o worker errado responde 404 Session not found. Deploy e escala tem a história completa de múltiplos workers.
  • stateless_http=True é a única chave, e ela vale apenas para a perna legada. Ela compra balanceamento de carga livre para clientes legados ao preço dos dois canais servidor-para-cliente nessa perna: requisições iniciadas pelo servidor levantam NoBackChannelError (um erro de nível superior no cliente, não um resultado is_error), e as notificações são descartadas.
  • Uma conexão 2026-07-28 é sem sessão de qualquer forma. stateless_http nunca a afeta.
  • O código do seu handler se bifurca por era em exatamente um lugar: notificações de mudança. ctx.notify_* alcança clientes subscriptions/listen; ctx.session.send_* alcança sessões legadas. Chame os dois.
  • Todo o resto (incluindo pedir entrada ao usuário, via Resolve) é portável entre eras por construção. Escreva a versão moderna uma vez só.