Elicitation
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.
Ein Tool, das mitten in seiner Arbeit steckt und dem eine Antwort fehlt, muss nicht scheitern.
Mit Elicitation (Rückfrage bei der Person am Host) kann es fragen. Mitten in einem Tool-Aufruf bekommt die Person eine Frage gestellt, und ihre Antwort landet wieder im selben Funktionsaufruf.
Es gibt zwei Modi:
- Formular-Modus: Du brauchst einen Wert (eine Bestätigung, ein Datum, eine Menge). Du beschreibst die Felder, der Client rendert das Formular.
- URL-Modus: Die Person muss woanders hin (ein OAuth-Zustimmungsbildschirm, eine Bezahlseite). Nichts von dem, was sie dort tut, läuft über das Protokoll.
Und es gibt zwei Wege zu fragen. Der Weg der Wahl ist ein Resolver: Du hängst die Frage an einen Parameter, und das SDK fragt – auf jeder Verbindung, egal welche Protokollgeneration der Client spricht. Der direkte Weg, await ctx.elicit(...), ist ein Request vom Server an den Client, ein Kanal, den es nur für einen Client auf einer Legacy-Verbindung gibt (Spec-Version 2025-11-25 oder älter). Beide stehen auf dieser Seite; fang mit dem Resolver an.
Mit einem Resolver fragen
Eine Frage, die das ganze Tool blockiert – bist du sicher? welches der drei passenden Konten? – lässt sich aus dem Tool-Body in einen Resolver herausziehen, und das Framework stellt sie für dich.
Ein Parameter mit der Annotation Annotated[T, Resolve(fn)] wird befüllt, indem fn vor dem Tool-Body läuft. Der Resolver gibt den Wert direkt zurück, wenn er ihn schon kennt, oder gibt Elicit(...) zurück, damit das Framework fragt:
from typing import Annotated
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.mcpserver import (
AcceptedElicitation,
CancelledElicitation,
DeclinedElicitation,
Elicit,
ElicitationResult,
Resolve,
)
mcp = MCPServer("Files")
_FOLDERS: dict[str, list[str]] = {"/tmp/empty": [], "/tmp/project": ["main.py", "README.md"]}
class Confirm(BaseModel):
ok: bool
async def confirm_delete(path: str) -> Confirm | Elicit[Confirm]:
"""Resolver: ask for confirmation only when the folder is not empty."""
file_count = len(_FOLDERS.get(path, []))
if file_count == 0:
return Confirm(ok=True) # nothing to confirm, no round-trip to the client
return Elicit(f"{path} has {file_count} file(s). Delete anyway?", Confirm)
@mcp.tool()
async def delete_folder(
path: str,
confirm: Annotated[ElicitationResult[Confirm], Resolve(confirm_delete)],
) -> str:
"""Delete a folder, asking for confirmation when it is not empty."""
match confirm:
case AcceptedElicitation(data=Confirm(ok=True)):
_FOLDERS.pop(path, None)
return f"deleted {path}"
case AcceptedElicitation():
return "kept the folder"
case DeclinedElicitation():
return "declined: folder not deleted"
case CancelledElicitation():
return "cancelled: folder not deleted"
confirm_deleteliest das eigene Argumentpathdes Tools über den Namen aus, listet den Ordner auf und fragt nur, wenn es sein muss – ein leerer Ordner wird zuConfirm(ok=True)aufgelöst, ohne Roundtrip zum Client.delete_folderannotiertElicitationResult[Confirm], also injiziert das Framework das ganze Ergebnis, und das Tool behandelt permatchjeden Fall: annehmen und bestätigen, annehmen, aber behalten (ok=False), ablehnen, abbrechen.- Der Parameter
confirmtaucht nie im Input-Schema des Tools auf – der Client liefertpath, der Resolver liefertconfirm.
Annotiere stattdessen das unverpackte Modell (Annotated[Confirm, Resolve(confirm_delete)]), wenn das Tool nicht verzweigen muss: Beim Annehmen bekommt es das Modell, bei Ablehnen oder Abbrechen bricht der Aufruf mit einem Fehler ab.
Ein Resolver funktioniert auf jeder Verbindung. Einem Client auf einer Legacy-Verbindung schickt das SDK die Frage direkt; auf einer 2026-07-28-Verbindung gibt das SDK die Frage aus dem Aufruf zurück, und der nächste Versuch des Clients trägt die Antwort. Dein Resolver merkt den Unterschied nie; was unter der Haube passiert, steht in Multi-Roundtrip-Requests (multi-round-trip requests).
Fragen ist nur eines von dem, was ein Resolver kann. Der allgemeine Mechanismus – Abhängigkeiten, die rechnen, ohne zu fragen, Abhängigkeiten von Abhängigkeiten, was das Modell liefern kann und was nicht – ist die Seite Abhängigkeiten.
Aus dem Tool heraus fragen
Ein Tool kann auch mitten in seinem eigenen Body anhalten und fragen.
Warning
ctx.elicit() und ctx.elicit_url() sind Requests vom Server an den Client – ein
Kanal, den es nur für einen Client auf einer Legacy-Verbindung gibt (Spec-Version 2025-11-25
oder älter). Auf einer 2026-07-28-Verbindung gibt es keine vom Server initiierten Requests,
also schlagen diese Aufrufe fehl. Ein Resolver funktioniert auf beiden. Alles Weitere steht in
Protokollversionen.
await ctx.elicit() nimmt eine Nachricht und ein Pydantic-Modell entgegen:
from pydantic import BaseModel, Field
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
mcp = MCPServer("Bistro")
class AlternativeDate(BaseModel):
accept_alternative: bool = Field(description="Try another date?")
date: str = Field(default="2025-12-26", description="Alternative date (YYYY-MM-DD)")
@mcp.tool()
async def book_table(date: str, party_size: int, ctx: Context) -> str:
"""Book a table at the bistro."""
if date != "2025-12-25":
return f"Booked a table for {party_size} on {date}."
result = await ctx.elicit(
message=f"No tables for {party_size} on {date}. Would you like to try another date?",
schema=AlternativeDate,
)
if result.action == "accept" and result.data.accept_alternative:
return await book_table(result.data.date, party_size, ctx)
return "No booking made."
- Der
Context-Parameter gibt dirctx.elicit; jedes Tool kann einen entgegennehmen. Dieses Objekt hat seine eigene Seite: Der Context. AlternativeDateist das Schema der Antwort, die du haben willst.- Das Tool ist
async def. Das muss es sein: Es hält mittendrin an und wartet auf einen Menschen. - An jedem anderen Datum gibt das Tool sofort zurück. Es fragt nur, wenn es muss.
- Das Datum, das die Person annimmt, läuft wieder durch
book_tableselbst. Eine Antwort ist Eingabe wie jede andere: Ist die Alternative ebenfalls ausgebucht, wird erneut gefragt statt blind bestätigt.
Was der Client erhält
Der Client bekommt deine Nachricht und daneben ein JSON Schema, das aus dem Modell generiert wird:
{
"properties": {
"accept_alternative": {
"description": "Try another date?",
"title": "Accept Alternative",
"type": "boolean"
},
"date": {
"default": "2025-12-26",
"description": "Alternative date (YYYY-MM-DD)",
"title": "Date",
"type": "string"
}
},
"required": ["accept_alternative"],
"title": "AlternativeDate",
"type": "object"
}
Dieses Schema ist das Formular. Field(description=...) ist die Beschriftung; ein Standardwert füllt das Eingabefeld vor und macht das Feld optional. Es ist dieselbe Pydantic-zu-JSON-Schema-Maschinerie, die Tools für die Argumente eines Tools beschreibt.
Warning
Ein Elicitation-Schema ist nicht so ausdrucksstark wie das Input-Schema eines Tools. Nur flache,
primitive Felder: str, int, float, bool oder ein Literal aus Strings (daraus wird ein enum).
Steckst du ein Modell in das Modell, löst ctx.elicit eine Exception aus, bevor irgendetwas an den Client geht:
TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition
Du unterbrichst einen Menschen mitten in einer Aufgabe. Wenn die Antwort Verschachtelung braucht, hätte sie ein Argument des Tools sein sollen.
Die drei Antworten
result.action sagt dir, was die Person getan hat, und es gibt genau drei Möglichkeiten:
"accept": Sie hat das Formular abgeschickt.result.dataist eineAlternativeDate-Instanz, bereits validiert."decline": Sie hat Nein gesagt."cancel": Sie hat die Frage weggeklickt, ohne sich zu entscheiden.
result.data existiert nur bei "accept", deshalb prüft das Beispiel zuerst result.action. Dein Type Checker erzwingt die Reihenfolge: Nach result.action == "accept" ist result.data ein AlternativeDate; davor gibt es gar kein .data.
Eine Absage ist kein Fehler. Das Tool entscheidet, was Ablehnen bedeutet (hier: keine Buchung), und antwortet dem Modell ganz normal.
Tip
Die Antwort wird gegen dein Modell validiert, bevor dein Code sie sieht. Ein Client, der
"maybe" für ein bool schickt, bringt deine Buchung nicht durcheinander: Der Aufruf schlägt mit einem
Schema-Mismatch-Fehler fehl, dein if läuft nie.
Die Person zu einer URL schicken
Manche Dinge dürfen nicht durch das Modell oder den Client laufen: Zugangsdaten, Kartennummern, OAuth-Zustimmung. Dafür fragst du nicht nach Daten; du bittest die Person, irgendwohin zu gehen:
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
mcp = MCPServer("Bistro")
@mcp.tool()
async def pay_deposit(booking_id: str, ctx: Context) -> str:
"""Take the deposit that confirms a booking."""
result = await ctx.elicit_url(
message="A 20 EUR deposit confirms your booking.",
url=f"https://pay.example.com/deposit/{booking_id}",
elicitation_id=f"deposit-{booking_id}",
)
if result.action == "accept":
return "Complete the payment in your browser."
return "No deposit taken. The booking expires in one hour."
@mcp.tool()
async def confirm_deposit(booking_id: str, ctx: Context) -> str:
"""Record a payment reported by the payment provider."""
await ctx.session.send_elicit_complete(f"deposit-{booking_id}")
return f"Deposit received for booking {booking_id}."
ctx.elicit_url()nimmt die Nachricht, die zu besuchende URL und eineelicitation_iddeiner Wahl entgegen: einen beliebigen String, der diese Elicitation innerhalb deines Servers identifiziert.- Das Ergebnis hat eine Action und sonst nichts.
"accept"heißt, die Person hat zugestimmt, die URL zu öffnen, nicht, dass sie das, was auf der anderen Seite wartet, abgeschlossen hat. - Die Zahlung läuft außerhalb des Protokolls, zwischen dem Browser der Person und deinem Zahlungsanbieter. Über MCP kommt nie irgendein Inhalt zurück.
Sieh dir das zweite Tool an. Wenn dein Server erfährt, dass der externe Ablauf abgeschlossen ist (ein Webhook, ein Poll; hier als zweites Tool modelliert), sendet ctx.session.send_elicit_complete(...) die Benachrichtigung notifications/elicitation/complete mit derselben elicitation_id. So weiß der Client, dass er „waiting for payment...“ nicht mehr anzeigen muss. Ohne sie kann der Client nur raten.
Die Client-Seite
Server fragen. Clients antworten, indem sie Client(...) einen elicitation_callback übergeben:
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.types import ElicitRequestParams, ElicitRequestURLParams, ElicitResult
async def handle_elicitation(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
if isinstance(params, ElicitRequestURLParams):
print(f"Open this link to continue: {params.url}")
return ElicitResult(action="accept")
print(params.message)
return ElicitResult(action="accept", content={"accept_alternative": True, "date": "2025-12-27"})
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("book_table", {"date": "2025-12-25", "party_size": 2})
print(result.content)
- Ein Callback behandelt beide Modi.
paramsist eine Union ausElicitRequestFormParamsundElicitRequestURLParams;isinstanceist die Verzweigung. - Bei einer URL zeigst du der Person
params.urlund gibst die Action zurück, die sie gewählt hat. Niemals irgendeincontent. - Bei einem Formular rendert eine echte Anwendung
params.requested_schemaund gibt die Eingabe der Person alscontentzurück. Dieser hier sagt immer Ja mit einer vorgefertigten Antwort – genau der Callback, den du in einem Test willst. - Den Callback zu übergeben ist zugleich die Capability-Deklaration: So erfährt der Server, dass dieser Client gefragt werden kann. Was ein Client sonst noch für einen Server beantworten kann, steht in Client-Callbacks.
Info
Elicitation ist ein Request vom Server an den Client, und solche gibt es nur auf einer
Session mit klassischem Handshake, deshalb übergibt dieser Client mode="legacy".
Auf einer 2026-07-28-Verbindung fragt ein Tool stattdessen, indem es die Frage aus dem Aufruf
zurückgibt; dieser Ablauf steht in Multi-Roundtrip-Requests.
Ausprobieren
Starte die server.py des ctx.elicit-Formular-Modus (die mit book_table) über Streamable HTTP (den Einzeiler dafür hat Den Server betreiben), führe dann main() des Clients aus und frage book_table nach dem ersten Weihnachtstag.
Der Callback gibt die Frage aus, die er bekommen hat:
No tables for 2 on 2025-12-25. Would you like to try another date?
Er antwortet mit {"accept_alternative": True, "date": "2025-12-27"}, und das Tool, das die ganze Zeit in await ctx.elicit(...) gewartet hat, schließt die Buchung ab:
Booked a table for 2 on 2025-12-27.
Tausche nun die server.py des URL-Modus ein und richte dasselbe main() auf pay_deposit: Derselbe Callback nimmt den anderen Zweig, gibt den Bezahllink aus, und das Tool kommt mit „Complete the payment in your browser.“ zurück. Ein Roundtrip, mitten im Aufruf, in beide Richtungen.
Check
Entferne nun elicitation_callback= aus dem Client und rufe book_table noch einmal für den ersten
Weihnachtstag auf. Der ganze Aufruf schlägt mit einem Protokollfehler fehl:
Elicitation not supported
Ein Client, der keinen Callback registriert hat, hat die Capability elicitation nie deklariert, also gibt es
niemanden zum Fragen. Dein Tool hat kein "decline" bekommen; es hat eine Exception bekommen. Plane dafür: Jede
Elicitation braucht eine sinnvolle Antwort auf „Was, wenn ich nicht fragen kann?“.
Zusammenfassung
- Ein Parameter mit der Annotation
Annotated[T, Resolve(fn)]wird von einem Resolver befüllt, derElicit(...)zurückgibt, wenn er fragen muss. Das funktioniert auf jeder Verbindung. - Das Schema ist ein flaches Pydantic-Modell: nur primitive Felder, auf dem Rückweg validiert.
result.actionist"accept","decline"oder"cancel";result.dataexistiert nur bei Accept.await ctx.elicit(message, schema=Model)fragt aus dem Tool-Body heraus, undawait ctx.elicit_url(message, url, elicitation_id)ist für alles, was nicht durch das Modell laufen darf (ctx.session.send_elicit_complete(elicitation_id)meldet, dass der externe Teil erledigt ist). Beide sind Server-zu-Client-Requests: Sie brauchen den Client auf einer Legacy-Verbindung.- Der Client antwortet mit einem einzigen
elicitation_callback, der nach dem Typ der Params verzweigt; ihn zu registrieren deklariert die Capability. - Auf einer 2026-07-28-Verbindung gibt der Server die Frage zurück, statt sie zu pushen; derselbe Callback wird von Multi-Roundtrip-Requests gespeist.
Alles unterhalb dieser Rückgabe (die Retry-Schleife, der Schutz von requestState, es selbst zu steuern) steht in Multi-Roundtrip-Requests.