Pular para conteúdo

Callbacks do cliente

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.

Quase toda requisição no MCP vai em um só sentido: do cliente para o servidor.

Um servidor também pode pedir coisas ao cliente: fazer uma pergunta ao usuário, amostrar o modelo do usuário, listar as pastas do workspace do usuário. Você responde a essas requisições passando callbacks para Client(...).

Um servidor que pergunta

Aqui está um servidor cuja ferramenta não consegue terminar sozinha:

server.py
from pydantic import BaseModel

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

mcp = MCPServer("Library")


class CardHolder(BaseModel):
    name: str


@mcp.tool()
async def issue_card(ctx: Context) -> str:
    """Issue a new library card."""
    answer = await ctx.elicit("What name should go on the card?", schema=CardHolder)
    if answer.action == "accept":
        return f"Card issued to {answer.data.name}."
    return "No card issued."
  • ctx.elicit(...) envia uma requisição elicitation/create para o cliente e espera.
  • A ferramenta não retorna até que alguém (uma pessoa em um formulário, ou o seu código) forneça um name.

Essa é a metade do servidor, e a página Elicitação cuida dela. Esta página é a outra ponta do fio.

O callback de elicitação

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


async def handle_elicitation(
    context: ClientRequestContext,
    params: ElicitRequestParams,
) -> ElicitResult:
    return ElicitResult(action="accept", content={"name": "Ada Lovelace"})


async def main() -> None:
    async with Client(
        "http://127.0.0.1:8000/mcp",
        mode="legacy",
        elicitation_callback=handle_elicitation,
    ) as client:
        result = await client.call_tool("issue_card")
        print(result.content)
  • Um callback de elicitação (elicitation) é async (context, params) -> ElicitResult.
  • params.message é a pergunta. params.requested_schema é o JSON Schema da resposta que o servidor quer. Um cliente de verdade renderiza um formulário a partir dele; este aqui preenche automaticamente.
  • Você retorna ElicitResult(action="accept", content={...}), ou action="decline", ou action="cancel". A única outra opção é ErrorData(...), que recusa a requisição e faz a chamada inteira falhar.
  • context é um ClientRequestContext: a session ativa, o request_id do servidor e qualquer meta que ele tenha anexado.

Tip

params é uma união dos dois modos de elicitação. Aqui params.mode é "form"; uma requisição "url" traz params.url em vez de um schema. Um único callback trata os dois; ramifique em params.mode. Elicitação mostra o padrão completo.

Experimente

Chame issue_card e observe as duas pontas.

Seu callback recebe a pergunta do servidor, já analisada:

params.mode              # 'form'
params.message           # 'What name should go on the card?'
params.requested_schema  # {'properties': {'name': {'title': 'Name', 'type': 'string'}},
                         #  'required': ['name'], 'title': 'CardHolder', 'type': 'object'}

Ele responde, ctx.elicit(...) retoma dentro da ferramenta, e a ferramenta termina:

result.content  # [TextContent(type='text', text='Card issued to Ada Lovelace.')]

Um tools/call seu, um elicitation/create de volta do servidor, respondido pela sua função, tudo dentro de uma única chamada de ferramenta.

Info

O mode="legacy" na chamada Client(...) está fazendo trabalho de verdade. Por padrão, Client(...) negocia o caminho moderno do protocolo, e esse caminho não tem canal de retorno (back-channel) para requisições do servidor ao cliente: ctx.elicit falha antes mesmo de o seu callback rodar. Não é o transporte que decide isso; é o protocolo negociado, tanto em memória quanto por uma URL. Fixe mode="legacy" sempre que o seu cliente tiver que responder a uma; todos os testes por trás desta página fazem isso. Versões do protocolo tem a história completa.

Em uma sessão 2026-07-28 o callback não está morto, ele é alimentado de outro jeito: quando uma ferramenta retorna um InputRequiredResult carregando um ElicitRequest, o Client despacha essa entrada para o mesmo elicitation_callback e refaz a chamada para você. Esse fluxo está em Requisições de múltiplas idas e voltas.

Um callback é uma capacidade

Você nunca disse ao servidor que o seu cliente consegue responder a requisições de elicitação. O SDK disse.

Quando um cliente se conecta, ele declara suas capabilities, a imagem espelhada das do servidor. Você não escreve esse objeto. Registrar um callback é a declaração.

você passa o cliente declara
elicitation_callback= "elicitation": {"form": {}, "url": {}}
sampling_callback= "sampling": {}
list_roots_callback= "roots": {"listChanged": true}
nenhum deles {}

As subcapacidades de amostragem (sampling) são o único refinamento: passe sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability()) junto com sampling_callback quando o seu amostrador trata os parâmetros tools / tool_choice. Os servidores precisam ver sampling.tools declarado antes de poderem enviá-los.

logging_callback e message_handler não estão na tabela. Eles tratam notificações, e notificações não precisam de capacidade.

O servidor lê a declaração de volta com ctx.session.check_client_capability(...). Adicione uma ferramenta que faça isso:

server.py
from pydantic import BaseModel

from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from mcp.types import ClientCapabilities, ElicitationCapability, RootsCapability, SamplingCapability

mcp = MCPServer("Library")


class CardHolder(BaseModel):
    name: str


@mcp.tool()
async def issue_card(ctx: Context) -> str:
    """Issue a new library card."""
    answer = await ctx.elicit("What name should go on the card?", schema=CardHolder)
    if answer.action == "accept":
        return f"Card issued to {answer.data.name}."
    return "No card issued."


@mcp.tool()
def client_features(ctx: Context) -> list[str]:
    """Which optional features the connected client declared."""
    declared = {
        "elicitation": ClientCapabilities(elicitation=ElicitationCapability()),
        "sampling": ClientCapabilities(sampling=SamplingCapability()),
        "roots": ClientCapabilities(roots=RootsCapability()),
    }
    return [name for name, capability in declared.items() if ctx.session.check_client_capability(capability)]

Conecte com apenas elicitation_callback e chame-a:

result.structured_content  # {'result': ['elicitation']}

Passe os três callbacks e você recebe ['elicitation', 'sampling', 'roots']. Não passe nenhum e você recebe [].

Check

Agora faça a coisa errada: conecte sem elicitation_callback e chame issue_card mesmo assim.

A requisição elicitation/create do servidor ainda chega ao seu cliente, e o SDK a responde por você, com um erro, porque você nunca disse que conseguiria tratá-la. Esse erro afunda a chamada inteira. call_tool não retorna um resultado is_error; ele levanta uma exceção:

MCPError: Elicitation not supported

Isso é um erro de protocolo (-32600, invalid request), não um erro de ferramenta: não há nada para o modelo ler e tentar de novo. É por isso que vale a pena ter client_features: um servidor bem-comportado verifica antes de perguntar.

O par descontinuado

sampling_callback responde a sampling/createMessage: o servidor pedindo ao seu modelo que complete algo. list_roots_callback responde a roots/list: o servidor perguntando em quais diretórios ele pode trabalhar.

Os dois funcionam. Os dois seguem a regra acima. E os dois atendem RPCs que a spec 2026-07-28 remove: um servidor moderno não chama de volta o seu cliente no meio de uma requisição, ele devolve a requisição para você como parte do resultado da ferramenta (Requisições de múltiplas idas e voltas). Os callbacks em si não estão mortos. Quando um InputRequiredResult carrega um CreateMessageRequest ou um ListRootsRequest, o loop automático do Client o despacha para o mesmo sampling_callback ou list_roots_callback que você registrou aqui. A lista inteira está em Funcionalidades descontinuadas.

Você ainda precisa dos callbacks para falar com servidores que não migraram. As assinaturas:

client.py
from pydantic import FileUrl

from mcp.client import ClientRequestContext
from mcp.types import CreateMessageRequestParams, CreateMessageResult, ListRootsResult, Root, TextContent


async def handle_sampling(
    context: ClientRequestContext,
    params: CreateMessageRequestParams,
) -> CreateMessageResult:
    return CreateMessageResult(
        role="assistant",
        content=TextContent(type="text", text="The answer is 42."),
        model="my-llm",
    )


async def handle_list_roots(context: ClientRequestContext) -> ListRootsResult:
    return ListRootsResult(roots=[Root(uri=FileUrl("file:///home/ada/notebooks"), name="notebooks")])
  • Um callback de amostragem recebe o CreateMessageRequestParams completo (messages, model_preferences, max_tokens) e retorna um CreateMessageResult. Você executa o modelo, do jeito que quiser; o SDK só transporta a requisição.
  • Um callback de roots não recebe parâmetro nenhum e retorna um ListRootsResult.
  • Qualquer um dos dois pode retornar ErrorData(...) no lugar, para recusar.

Passe-os para Client(...) exatamente como elicitation_callback.

Os callbacks de notificação

Mais dois. Nenhum deles declara nada.

logging_callback recebe as notifications/message que um servidor envia, como LoggingMessageNotificationParams (level, logger, data). O logging de protocolo em si foi descontinuado pela spec 2026-07-28 (Logging diz o que fazer no lugar), então esse callback existe para os servidores que ainda o emitem. Em uma conexão da era 2026, o callback sozinho não te dá nada, porque servidores 2026 enviam mensagens de log apenas para requisições que optam por recebê-las: passe log_level="info" (ou outro nível) para Client(...) para carimbar essa opção em toda requisição e receber esse nível e acima. Servidores pré-2026 o ignoram e mantêm o comportamento de logging/setLevel.

message_handler é o pega-tudo: toda notificação do servidor que a sessão expõe chega até ele (além do callback específico dela), e em um transporte baseado em stream toda Exception no nível do transporte também. Duas nunca chegam: notifications/cancelled é aplicada pelo SDK em vez de exposta, e a confirmação de assinatura de um stream listen() ativo é consumida por esse stream. Anote o parâmetro com IncomingMessage (ServerNotification | Exception, exportado de mcp.client). O único padrão que vale conhecer é if isinstance(message, Exception): raise message, para que uma conexão quebrada falhe em alto e bom som em vez de sumir.

Recapitulando

  • Um servidor pode enviar requisições ao cliente. Você as responde com callbacks passados para Client(...).
  • O callback de elicitação é o atual: async (context, params) -> ElicitResult, uma função para os modos formulário e URL.
  • Registrar um callback é declarar a capacidade. Sem ele, o SDK recusa a requisição do servidor em seu nome e a chamada inteira falha com MCPError.
  • Um servidor descobre antes de perguntar com ctx.session.check_client_capability(...).
  • sampling_callback e list_roots_callback funcionam do mesmo jeito, mas atendem funcionalidades descontinuadas; servidores modernos usam requisições de múltiplas idas e voltas no lugar.
  • logging_callback e message_handler recebem notificações. Eles não declaram nada.

O primeiro argumento de Client(...) é um objeto de transporte. Transportes do cliente cobre todos os tipos.