Recipe: elicitation — forms and destructive-action confirmations¶
Elicitation lets a tool pause mid-call and ask the user for input. Two Django-shaped uses cover most of what you will want it for:
- Filling in a form — a tool was called with missing or invalid fields; instead of failing, it asks for exactly what a Django form says is wrong.
- Confirming a destructive action — an update or delete does not run until the user has explicitly approved it.
Both patterns need request_state_security() wired, so the answer can land on any worker.
Which clients support it¶
Elicitation is the most unevenly adopted part of the spec, so check your users' client before you design a flow around it. Support is per product, not per vendor — Claude Code has it and Claude.ai does not.
| Client | Elicitation | Notes |
|---|---|---|
| Claude Code (CLI) | ✅ | Since v2.1.76 (14 Mar 2026). Elicitation and ElicitationResult hooks let users auto-answer. |
| Claude.ai (web) | ❌ | Tracked in anthropics/claude-ai-mcp#153, open. |
| Claude Desktop | ❌ | Tracked in anthropics/claude-code#41110, open. |
| Codex CLI | ✅ | Server-driven elicitation merged in openai/codex#17043 (8 Apr 2026). |
| ChatGPT (web, developer mode) | ❌ | No documented support; interactive UI goes through the Apps SDK widget model instead. |
| Gemini CLI | ❌ | Answers Method not found. Tracked in #22249 and, for URL mode, #15613. |
| Gemini (consumer app) | ❌ | No self-serve third-party MCP servers at all — Spark connectors are partnership-only. |
| GitHub Copilot (VS Code) | ✅ | Form and URL modes; server-supplied defaults since v1.105. |
| GitHub Copilot (Visual Studio) | ✅ | Answered in the chat window. VS 2026, backported to VS 2022 17.14+. |
| Cursor | ✅ | Since v1.5 (Aug 2025), form mode only; string, number, boolean and enum schemas. |
| MCP Inspector | ✅ | Renders each elicitation as a real form — the quickest way to try yours. |
Checked 10 August 2026; treat it as a snapshot and confirm against your client's own release notes.
A client without elicitation does not degrade on your behalf, and the two patterns below want opposite things from that.
Form filling should still work: take the values as ordinary arguments and elicit only the corrections, as create_customer does, so a client that cannot ask still succeeds whenever the agent supplied valid fields.
Destructive confirmation should not: if the client cannot put the question to a human, the safe outcome is that delete_customer never deletes.
That is what the code below already does — an unanswered request means no confirmation, and no confirmation means no delete.
How a tool asks¶
On the 2026-07-28 spec there is no held connection and no callback.
The tool returns the SDK's InputRequiredResult naming what it needs; the client renders that as a form and retries the same call with the answers attached.
The tool runs top-to-bottom both times — the retry finds the answers in ctx.input_responses and finishes:
from mcp.server.mcpserver import Context
from mcp.types import (
ElicitRequest,
ElicitRequestFormParams,
ElicitResult,
InputRequiredResult,
)
def _ask(message: str, properties: dict[str, dict[str, str]]) -> ElicitRequest:
"""Build a form elicitation for the given JSON-schema properties."""
return ElicitRequest(
params=ElicitRequestFormParams(
message=message,
requested_schema={
"type": "object",
"properties": properties,
"required": list(properties),
},
)
)
Each answer is an ElicitResult whose action is "accept", "decline", or "cancel", with the submitted values in .content on accept.
Treat anything but an explicit accept as a no.
Confirming a destructive action¶
The tool asks before it deletes, and only an accepted True runs the ORM delete.
Pair it with destructive_hint so well-behaved clients add their own confirmation UX on top — annotations are hints, this elicitation is the enforcement:
from mcp.types import ToolAnnotations
from customers.models import Customer
@server.tool(annotations=ToolAnnotations(destructive_hint=True, open_world_hint=False))
def delete_customer(ctx: Context, customer_id: int) -> str | InputRequiredResult:
"""Delete a customer, after explicit confirmation."""
answer = (ctx.input_responses or {}).get("confirm_delete")
if answer is None:
return InputRequiredResult(
input_requests={
"confirm_delete": _ask(
f"Really delete customer {customer_id}? This cannot be undone.",
{"confirm": {"type": "boolean"}},
)
}
)
accepted = isinstance(answer, ElicitResult) and answer.action == "accept"
if not accepted or not (answer.content or {}).get("confirm"):
return "Delete cancelled."
deleted, _ = Customer.objects.filter(pk=customer_id).delete()
return f"Deleted {deleted} customer(s)."
The two requests are independent — the confirmation can be answered by a different worker, or a worker started after the question was asked.
Filling in a form¶
Let a Django form own validation, exactly as it would for a browser: the tool feeds it whatever the agent supplied, and elicits corrections for the fields the form rejects — quoting the form's own error messages:
from django import forms
from customers.models import Customer
class CustomerForm(forms.ModelForm):
class Meta:
model = Customer
fields = ["name", "email"]
@server.tool()
def create_customer(
ctx: Context,
name: str = "",
email: str = "",
) -> str | InputRequiredResult:
"""Create a customer, asking for whatever the form finds missing or invalid."""
supplied = {"name": name, "email": email}
answer = (ctx.input_responses or {}).get("corrections")
if isinstance(answer, ElicitResult):
if answer.action != "accept":
return "Creation cancelled."
corrections = answer.content or {}
supplied |= {k: v for k, v in corrections.items() if isinstance(v, str)}
form = CustomerForm(data=supplied)
if form.is_valid():
customer = form.save()
return f"Created customer {customer.pk}: {customer.name}"
problems = [f"{field}: {' '.join(errors)}" for field, errors in form.errors.items()]
return InputRequiredResult(
input_requests={
"corrections": _ask(
f"Please correct: {'; '.join(problems)}",
{field: {"type": "string"} for field in form.errors},
)
}
)
Call it with email="not-an-email" and the user is asked to correct exactly that field, with Django's own "Enter a valid email address." as the reason.
If a correction is still invalid, the same code path simply asks again — multi-round flows need nothing extra.
Because the retry replays the original arguments alongside the answers, valid fields never need re-entering; and because validation lives in the form, the rules stay identical whether the customer arrives via this tool or your ordinary views.
Seeing it live¶
The example project's test_input_required_result_* fixtures run these mechanics — including multi-round flows and tampered-state rejection — and MCP Inspector renders each elicitation as a real form.