Élicitation
Traduction automatique
Cette page a été traduite automatiquement à partir de la documentation en anglais, et la page en anglais fait foi. Si quelque chose vous semble incorrect, la page Traductions explique comment le signaler.
Un outil arrivé à mi-parcours de sa tâche et à qui il manque une seule réponse n’est pas obligé d’échouer.
L’élicitation (elicitation) lui permet de la demander. En plein appel d’outil, l’utilisateur reçoit une question, et sa réponse revient dans le même appel de fonction.
Il existe deux modes :
- Mode formulaire : vous avez besoin d’une valeur (une confirmation, une date, une quantité). Vous décrivez les champs, le client affiche le formulaire.
- Mode URL : vous avez besoin que l’utilisateur aille ailleurs (un écran de consentement OAuth, une page de paiement). Rien de ce qu’il y fait ne passe par le protocole.
Et il existe deux façons de demander. Celle à privilégier est un résolveur : vous accrochez la question à un paramètre, et le SDK la pose — sur n’importe quelle connexion, quelle que soit la génération de protocole que parle le client. La façon directe, await ctx.elicit(...), est une requête du serveur vers le client, un canal qui n’existe que pour un client sur une connexion historique (version de spécification 2025-11-25 ou antérieure). Les deux figurent sur cette page ; commencez par le résolveur.
Demander avec un résolveur
Une question dont dépend tout l’outil — êtes-vous sûr ? lequel des trois comptes correspondants ? — peut être sortie du corps de l’outil et placée dans un résolveur, et le framework la pose pour vous.
Un paramètre annoté Annotated[T, Resolve(fn)] est rempli en exécutant fn avant le corps de l’outil. Le résolveur renvoie directement la valeur quand il la connaît déjà, ou renvoie Elicit(...) pour que le framework pose la question :
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_deletelit par son nom l’argumentpathde l’outil lui-même, liste le dossier et n’élicite que lorsqu’il le doit — un dossier vide se résout enConfirm(ok=True)sans aucun aller-retour avec le client.delete_folderannoteElicitationResult[Confirm]: le framework injecte donc le résultat complet et l’outil traite chaque cas avecmatch: accepter et confirmer, accepter mais conserver (ok=False), décliner, annuler.- Le paramètre
confirmn’apparaît jamais dans le schéma d’entrée de l’outil — le client fournitpath, le résolveur fournitconfirm.
Annotez plutôt le modèle non enveloppé (Annotated[Confirm, Resolve(confirm_delete)]) quand l’outil n’a pas besoin de bifurquer : il reçoit le modèle en cas d’acceptation, et l’appel s’interrompt avec une erreur en cas de refus ou d’annulation.
Un résolveur fonctionne sur toutes les connexions. Pour un client sur une connexion historique, le SDK lui envoie directement la question ; sur une connexion 2026-07-28, le SDK renvoie la question depuis l’appel, et la tentative suivante du client transporte la réponse. Votre résolveur ne voit jamais la différence ; ce qui se passe sous le capot, ce sont les Requêtes à plusieurs allers-retours (multi-round-trip).
Demander n’est qu’une des choses qu’un résolveur peut faire. Le mécanisme général — des dépendances qui calculent sans demander, des dépendances de dépendances, ce que le modèle peut et ne peut pas fournir — est décrit sur la page Dépendances.
Demander depuis l’intérieur de l’outil
Un outil peut aussi s’arrêter au milieu de son propre corps et poser une question.
Warning
ctx.elicit() et ctx.elicit_url() sont des requêtes du serveur vers le client — un
canal qui n’existe que pour un client sur une connexion historique (version de spécification 2025-11-25
ou antérieure). Sur une connexion 2026-07-28, il n’y a pas de requêtes à l’initiative du serveur, donc
ces appels échouent. Un résolveur fonctionne sur les deux. Tous les détails sont dans
Versions du protocole.
await ctx.elicit() prend un message et un modèle Pydantic :
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."
- Le paramètre
Contextest ce qui vous donnectx.elicit; n’importe quel outil peut en prendre un. Cet objet a sa propre page : L’objet Context. AlternativeDateest le schéma de la réponse que vous voulez.- L’outil est
async def. Il doit l’être : il s’arrête au milieu et attend une personne. - Pour toute autre date, l’outil renvoie immédiatement. Il ne demande que lorsqu’il le doit.
- La date que l’utilisateur accepte repasse par
book_tablelui-même. Une réponse est une entrée comme une autre : une date de remplacement elle aussi complète fait l’objet d’une nouvelle question, au lieu d’être confirmée à l’aveugle.
Ce que reçoit le client
Le client reçoit votre message et, à côté, un JSON Schema généré à partir du modèle :
{
"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"
}
Ce schéma, c’est le formulaire. Field(description=...) est le libellé ; une valeur par défaut préremplit le champ et le rend facultatif. C’est la même mécanique Pydantic vers JSON Schema que Outils décrit pour les arguments d’un outil.
Warning
Un schéma d’élicitation n’est pas aussi expressif que le schéma d’entrée d’un outil. Des champs plats et primitifs
uniquement : str, int, float, bool, ou un Literal de chaînes (il devient un enum).
Mettez un modèle dans le modèle et ctx.elicit lève une exception avant que quoi que ce soit ne soit envoyé au client :
TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition
Vous interrompez une personne en pleine tâche. Si la réponse a besoin d’imbrication, elle aurait dû être un argument de l’outil.
Les trois réponses
result.action vous indique ce qu’a fait l’utilisateur, et il y a exactement trois possibilités :
"accept": il a soumis le formulaire.result.dataest une instance deAlternativeDate, déjà validée."decline": il a dit non."cancel": il a écarté la question sans choisir.
result.data n’existe que sur "accept", c’est pourquoi l’exemple vérifie result.action d’abord. Votre vérificateur de types impose cet ordre : après result.action == "accept", result.data est un AlternativeDate ; avant, il n’y a pas de .data du tout.
Un refus n’est pas une erreur. L’outil décide de ce que signifie décliner (ici, pas de réservation) et répond normalement au modèle.
Tip
La réponse est validée par rapport à votre modèle avant que votre code ne la voie. Un client qui envoie
"maybe" pour un bool ne corrompt pas votre réservation : l’appel échoue avec une
erreur de non-conformité au schéma, votre if ne s’exécute jamais.
Envoyer l’utilisateur vers une URL
Certaines choses ne doivent passer ni par le modèle ni par le client : identifiants, numéros de carte, consentement OAuth. Pour celles-là, vous ne demandez pas de données ; vous demandez à l’utilisateur d’aller quelque part :
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()prend le message, l’URL à visiter et unelicitation_idque vous choisissez : n’importe quelle chaîne qui identifie cette élicitation au sein de votre serveur.- Le résultat contient une action et rien d’autre.
"accept"signifie que l’utilisateur a accepté d’ouvrir l’URL, pas qu’il a terminé ce qui se trouve de l’autre côté. - Le paiement a lieu hors bande, entre le navigateur de l’utilisateur et votre prestataire de paiement. Aucun contenu ne revient jamais par MCP.
Regardez le second outil. Quand votre serveur apprend que le flux hors bande est terminé (un webhook, une interrogation périodique ; ici, c’est modélisé par un second outil), ctx.session.send_elicit_complete(...) envoie notifications/elicitation/complete avec le même elicitation_id. C’est ainsi que le client sait qu’il peut cesser d’afficher « en attente du paiement… ». Sans cela, le client ne peut que deviner.
Côté client
Les serveurs demandent. Les clients répondent en passant une fonction de rappel (callback) elicitation_callback à Client(...) :
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)
- Une seule fonction de rappel gère les deux modes.
paramsest une union deElicitRequestFormParamsetElicitRequestURLParams;isinstancefait le branchement. - Pour une URL, vous montrez
params.urlà l’utilisateur et renvoyez l’action qu’il a choisie. Jamais decontent. - Pour un formulaire, une vraie application affiche
params.requested_schemaet renvoie la saisie de l’utilisateur commecontent. Celle-ci dit toujours oui avec une réponse toute faite, ce qui est exactement la fonction de rappel que vous voulez dans un test. - Passer la fonction de rappel constitue aussi la déclaration de capacité : c’est ainsi que le serveur apprend que ce client peut être interrogé. Les autres choses auxquelles un client peut répondre pour un serveur se trouvent dans Fonctions de rappel du client.
Info
L’élicitation est une requête du serveur vers le client, et celles-ci n’existent que sur une
session à poignée de main (handshake) classique, c’est pourquoi ce client passe mode="legacy".
Sur une connexion 2026-07-28, un outil demande plutôt en renvoyant la question depuis l’appel ;
ce flux, ce sont les Requêtes à plusieurs allers-retours.
Essayer
Démarrez le server.py en mode formulaire avec ctx.elicit (celui de book_table) sur Streamable HTTP (Exécuter votre serveur donne la commande en une ligne), puis exécutez le main() du client et demandez à book_table le jour de Noël.
La fonction de rappel affiche la question qui lui a été envoyée :
No tables for 2 on 2025-12-25. Would you like to try another date?
Elle répond avec {"accept_alternative": True, "date": "2025-12-27"}, et l’outil, qui attendait dans await ctx.elicit(...) pendant tout ce temps, termine la réservation :
Booked a table for 2 on 2025-12-27.
Remplacez-le maintenant par le server.py en mode URL et pointez le même main() vers pay_deposit : la même fonction de rappel prend l’autre branche, affiche le lien de paiement, et l’outil revient avec « Complete the payment in your browser. » Un aller-retour, en plein appel, dans les deux sens.
Check
Retirez maintenant elicitation_callback= du Client et appelez de nouveau book_table pour le jour de Noël.
L’appel entier échoue avec une erreur de protocole :
Elicitation not supported
Un client qui n’a enregistré aucune fonction de rappel n’a jamais déclaré la capacité elicitation, il n’y a donc
personne à qui demander. Votre outil n’a pas reçu de "decline" ; il a reçu une exception. Concevez en conséquence : chaque
élicitation a besoin d’une réponse sensée à la question « et si je ne peux pas demander ? ».
Récapitulatif
- Un paramètre annoté
Annotated[T, Resolve(fn)]est rempli par un résolveur, qui renvoieElicit(...)quand il doit demander. Cela fonctionne sur toutes les connexions. - Le schéma est un modèle Pydantic plat : des champs primitifs uniquement, validés au retour.
result.actionvaut"accept","decline"ou"cancel";result.datan’existe qu’en cas d’acceptation.await ctx.elicit(message, schema=Model)demande depuis l’intérieur du corps de l’outil, etawait ctx.elicit_url(message, url, elicitation_id)sert à tout ce qui ne doit pas passer par le modèle (ctx.session.send_elicit_complete(elicitation_id)indique que la partie hors bande est terminée). Les deux sont des requêtes du serveur vers le client : elles nécessitent que le client soit sur une connexion historique.- Le client répond avec une seule
elicitation_callback, en branchant sur le type des params ; l’enregistrer, c’est ce qui déclare la capacité. - Sur une connexion 2026-07-28, le serveur renvoie la question au lieu de la pousser ; la même fonction de rappel est alimentée par les Requêtes à plusieurs allers-retours.
Tout ce qui se trouve sous ce retour (la boucle de réessai, la protection de requestState, le pilotage à la main) est dans Requêtes à plusieurs allers-retours.