Client-Callbacks
Maschinelle Übersetzung
Diese Seite wurde automatisch aus der englischen Dokumentation übersetzt, und die englische Seite ist die maßgebliche Fassung. Wenn sich etwas falsch liest, erklärt Übersetzungen, wie du es melden kannst.
Fast jeder Request in MCP läuft in eine Richtung: vom Client zum Server.
Ein Server kann aber auch den Client um etwas bitten: der Person am Host eine Frage zu stellen, ihr Modell per Sampling zu nutzen, ihre Arbeitsverzeichnisse aufzulisten. Diese Requests beantwortest du, indem du Client(...) Callbacks übergibst.
Ein Server, der fragt
Hier ist ein Server, dessen Tool allein nicht fertig werden kann:
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(...)sendet einenelicitation/create-Request an den Client und wartet.- Das Tool kehrt erst zurück, wenn jemand (eine Person in einem Formular oder dein Code) einen
nameliefert.
Das ist die Server-Hälfte, und die gehört der Seite Elicitation (Elicitation: Rückfrage bei der Person am Host). Diese Seite hier ist das andere Ende der Leitung.
Der Elicitation-Callback
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)
- Ein Elicitation-Callback ist
async (context, params) -> ElicitResult. params.messageist die Frage.params.requested_schemaist das JSON-Schema der Antwort, die der Server haben will. Ein echter Client rendert daraus ein Formular; dieser hier füllt es automatisch aus.- Du gibst
ElicitResult(action="accept", content={...})zurück, oderaction="decline", oderaction="cancel". Die einzige andere Möglichkeit istErrorData(...): Das weist den Request zurück und lässt den gesamten Aufruf fehlschlagen. contextist einClientRequestContext: die laufendesession, dierequest_iddes Servers und alles, was er anmetaangehängt hat.
Tip
params ist eine Union der beiden Elicitation-Modi. Hier ist params.mode gleich "form"; ein "url"-Request
trägt params.url statt eines Schemas. Ein Callback behandelt beide; verzweige anhand von params.mode.
Elicitation zeigt das vollständige Muster.
Ausprobieren
Rufe issue_card auf und beobachte beide Enden.
Dein Callback erhält die Frage des Servers, bereits geparst:
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'}
Er antwortet, ctx.elicit(...) läuft im Tool weiter, und das Tool wird fertig:
result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')]
Ein tools/call von dir, ein elicitation/create zurück vom Server, beantwortet von deiner Funktion – alles innerhalb eines einzigen Tool-Aufrufs.
Info
mode="legacy" im Client(...)-Aufruf leistet echte Arbeit. Standardmäßig handelt Client(...) den modernen
Protokollpfad aus, und dieser Pfad hat keinen Rückkanal (back-channel) für Requests vom Server an den Client: ctx.elicit
schlägt fehl, bevor dein Callback überhaupt läuft. Das entscheidet nicht der Transport, sondern das ausgehandelte
Protokoll – in-memory genauso wie über eine URL. Setze mode="legacy" fest, wann immer dein Client
einen solchen Request beantworten muss; jeder Test hinter dieser Seite tut das. Alles Weitere steht in Protokollversionen.
In einer 2026-07-28-Session ist der Callback nicht tot, er wird nur anders gespeist: Gibt ein Tool ein
InputRequiredResult zurück, das einen ElicitRequest trägt, leitet Client diesen Eintrag an denselben
elicitation_callback weiter und wiederholt den Aufruf für dich. Dieser Ablauf heißt Multi-Roundtrip-Requests (multi-round-trip requests).
Ein Callback ist eine Capability
Du hast dem Server nie gesagt, dass dein Client Elicitation-Requests beantworten kann. Das SDK hat es getan.
Wenn sich ein Client verbindet, deklariert er seine capabilities, das Spiegelbild derer des Servers. Dieses Objekt schreibst du nicht. Einen Callback zu registrieren ist die Deklaration.
| du übergibst | der Client deklariert |
|---|---|
elicitation_callback= |
"elicitation": {"form": {}, "url": {}} |
sampling_callback= |
"sampling": {} |
list_roots_callback= |
"roots": {"listChanged": true} |
| keinen davon | {} |
Die Sampling-Sub-Capabilities sind die eine Verfeinerung: Übergib sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability()) zusammen mit sampling_callback, wenn dein Sampler die Parameter tools / tool_choice verarbeitet. Server müssen sampling.tools deklariert sehen, bevor sie diese senden dürfen.
logging_callback und message_handler stehen nicht in der Tabelle. Sie verarbeiten Benachrichtigungen, und Benachrichtigungen brauchen keine Capability.
Der Server liest die Deklaration mit ctx.session.check_client_capability(...) zurück. Füge ein Tool hinzu, das genau das tut:
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)]
Verbinde dich nur mit elicitation_callback und rufe es auf:
result.structured_content # {'result': ['elicitation']}
Übergibst du alle drei Callbacks, bekommst du ['elicitation', 'sampling', 'roots']. Übergibst du keinen, bekommst du [].
Check
Jetzt mach es absichtlich falsch: Verbinde dich ohne elicitation_callback und rufe issue_card trotzdem auf.
Der elicitation/create-Request des Servers erreicht deinen Client trotzdem, und das SDK beantwortet ihn für
dich – mit einem Fehler, weil du nie gesagt hast, dass du ihn verarbeiten kannst. Dieser Fehler lässt den gesamten Aufruf scheitern.
call_tool gibt kein is_error-Ergebnis zurück; es wirft eine Exception:
MCPError: Elicitation not supported
Das ist ein Protokollfehler (-32600, invalid request), kein Tool-Fehler: Es gibt nichts, was
das Modell lesen und erneut versuchen könnte. Deshalb lohnt sich client_features: Ein Server,
der sich gut benimmt, prüft, bevor er fragt.
Das veraltete Paar
sampling_callback beantwortet sampling/createMessage: Der Server bittet dein Modell um eine Completion. list_roots_callback beantwortet roots/list: Der Server fragt, in welchen Verzeichnissen er arbeiten darf.
Beide funktionieren. Beide folgen der Regel oben. Und beide bedienen RPCs, die die Spezifikation 2026-07-28 entfernt: Ein moderner Server ruft nicht mitten im Request in deinen Client zurück, sondern reicht dir den Request als Teil des Tool-Ergebnisses zurück (Multi-Roundtrip-Requests). Die Callbacks selbst sind nicht tot. Trägt ein InputRequiredResult einen CreateMessageRequest oder einen ListRootsRequest, leitet die Auto-Schleife von Client ihn an denselben sampling_callback oder list_roots_callback weiter, den du hier registriert hast. Die vollständige Liste steht in Veraltete Features.
Du brauchst die Callbacks weiterhin, um mit Servern zu sprechen, die noch nicht umgestiegen sind. Die Signaturen:
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")])
- Ein Sampling-Callback erhält die vollständigen
CreateMessageRequestParams(messages,model_preferences,max_tokens) und gibt einCreateMessageResultzurück. Du betreibst das Modell, ganz wie du willst; das SDK transportiert nur den Request. - Ein Roots-Callback nimmt überhaupt keine Parameter entgegen und gibt ein
ListRootsResultzurück. - Beide dürfen stattdessen
ErrorData(...)zurückgeben, um abzulehnen.
Übergib sie an Client(...) genau wie elicitation_callback.
Die Benachrichtigungs-Callbacks
Zwei weitere. Keiner deklariert etwas.
logging_callback erhält die notifications/message, die ein Server sendet, als LoggingMessageNotificationParams (level, logger, data). Das Protokoll-Logging selbst ist mit der Spezifikation 2026-07-28 veraltet (was du stattdessen tust, steht in Logging), dieser Callback existiert also für die Server, die es noch ausgeben. Auf einer Verbindung der 2026er-Generation bringt dir der Callback allein nichts, denn 2026er-Server senden Log-Nachrichten nur an Requests, die sich dafür anmelden: Übergib log_level="info" (oder ein anderes Level) an Client(...), um dieses Opt-in jedem Request aufzuprägen und dieses Level und alles darüber zu empfangen. Server vor 2026 ignorieren das und behalten ihr logging/setLevel-Verhalten.
message_handler ist das Sammelbecken: Jede Server-Benachrichtigung, die die Session nach oben reicht, landet dort (zusätzlich zu ihrem spezifischen Callback), und auf einem Stream-gestützten Transport auch jede Exception auf Transportebene. Zwei kommen nie an: notifications/cancelled wendet das SDK an, statt sie nach oben zu reichen, und eine Abonnement-Bestätigung für einen laufenden listen()-Stream verbraucht dieser Stream selbst. Annotiere den Parameter mit IncomingMessage (ServerNotification | Exception, exportiert aus mcp.client). Das eine Muster, das du kennen solltest, ist if isinstance(message, Exception): raise message, damit eine unterbrochene Verbindung laut fehlschlägt, statt still zu verschwinden.
Zusammenfassung
- Ein Server kann Requests an den Client senden. Du beantwortest sie mit Callbacks, die du
Client(...)übergibst. - Der Elicitation-Callback ist der aktuelle:
async (context, params) -> ElicitResult, eine Funktion für Formular- und URL-Modus. - Einen Callback zu registrieren heißt, die Capability zu deklarieren. Ohne ihn weist das SDK den Request des Servers in deinem Namen zurück, und der gesamte Aufruf schlägt mit
MCPErrorfehl. - Ein Server findet das vor dem Fragen mit
ctx.session.check_client_capability(...)heraus. sampling_callbackundlist_roots_callbackfunktionieren genauso, bedienen aber veraltete Features; moderne Server verwenden stattdessen Multi-Roundtrip-Requests.logging_callbackundmessage_handlerempfangen Benachrichtigungen. Sie deklarieren nichts.
Das erste Argument von Client(...) ist ein Transport-Objekt. Client-Transporte behandelt jede Art davon.