
This lesson is part of Learn MCP, our free course on the Model Context Protocol, within The MCP Guide.
In this lesson you will make a tool stop and ask the user a question before it acts. You will add a confirmation step to a bookshop "order" tool in Python and TypeScript and handle all three answers. You will also learn why the 2026-07-28 spec changed how this works.
Elicitation is the MCP feature that lets a server request information from the user during a request. The elicitation spec defines two modes. Form mode asks for structured values the host renders as a form. URL mode sends the user to a web page for anything sensitive.
The code adapts the Python Elicitation guide and the TypeScript input_required guide. It ran on 28 October 2026 with mcp 2.3.0 and @modelcontextprotocol/server 2.4.0.
Key Takeaways
- In the 2026-07-28 spec, a server no longer sends its own request to the client. It returns an
InputRequiredResult, and the client retries the call with the answer. - In Python, a resolver (
ResolveplusElicit) asks for you and works for both new and old clients. - In TypeScript, return
inputRequired(...)from the handler and read the answer withacceptedContent. - Every elicitation has three outcomes: accept, decline and cancel. Handle all three.
- Never ask for passwords, API keys or payment details in a form. The spec requires URL mode for those.
Step 1: Understand what changed in 2026-07-28
Before the 2026-07-28 spec, a server asked a question by sending its own elicitation/create request back to the client in the middle of a tool call. The Python multi-round-trip guide says the new spec "retires that back-channel."
Now the flow is:
- The client calls the tool.
- The server answers with an
InputRequiredResultthat lists the questions it needs answered. - The client asks the user, then calls the same tool again with the answers.
- The server finishes and returns a normal result.
Every step is a request from client to server. That fits the spec's stateless design, where any server instance can answer any request. Our spec reference on multi-round-trip requests covers the wire format.
Practical consequence: older SDK calls like Python's ctx.elicit() and TypeScript's ctx.mcpReq.elicitInput() only work on legacy connections. Both SDK docs say they fail on a 2026-07-28 connection.
Step 2: Add a confirmation in Python with a resolver
The Python SDK's recommended pattern is a resolver: a small function that supplies one tool parameter, and asks the user only when it must.
from typing import Annotated
from pydantic import BaseModel, Field
from mcp.server import MCPServer
from mcp.server.mcpserver import (
AcceptedElicitation,
CancelledElicitation,
DeclinedElicitation,
Elicit,
ElicitationResult,
Resolve,
)
mcp = MCPServer("Bookshop")
class ConfirmOrder(BaseModel):
ok: bool = Field(description="Place the order?")
gift_wrap: bool = Field(default=False, description="Add gift wrap?")
async def confirm_order(title: str, quantity: int) -> ConfirmOrder | Elicit[ConfirmOrder]:
"""Only ask when the order is large."""
if quantity <= 2:
return ConfirmOrder(ok=True)
return Elicit(f"Order {quantity} copies of {title}?", ConfirmOrder)
@mcp.tool()
async def order_book(
title: str,
quantity: int,
confirm: Annotated[ElicitationResult[ConfirmOrder], Resolve(confirm_order)],
) -> str:
"""Order copies of a book. Asks the user to confirm orders of more than 2 copies."""
match confirm:
case AcceptedElicitation(data=ConfirmOrder(ok=True, gift_wrap=wrap)):
return f"Ordered {quantity} x {title}" + (" with gift wrap." if wrap else ".")
case AcceptedElicitation():
return "The user chose not to order."
case DeclinedElicitation():
return "The user declined. Nothing was ordered."
case CancelledElicitation():
return "The user dismissed the question. Nothing was ordered."
How it works, per the Elicitation guide:
confirm_orderreads the tool's owntitleandquantityarguments by name.- Small orders resolve straight to
ConfirmOrder(ok=True)with no question at all. - Large orders return
Elicit(...), and the SDK asks the user. - The
confirmparameter never appears in the tool's input schema. The model suppliestitleandquantity; the resolver suppliesconfirm. ConfirmOrderis the form. Elicitation forms allow only flat, primitive fields: strings, numbers, booleans and string enums.
Step 3: Test both eras with a fake user
The Python Client answers elicitations through a callback. This test plays the user:
import anyio
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.types import ElicitRequestParams, ElicitResult
from server import mcp
async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
print(" asked:", params.message)
return ElicitResult(action="accept", content={"ok": True, "gift_wrap": True})
async def main() -> None:
for mode in ("auto", "legacy"):
async with Client(mcp, mode=mode, elicitation_callback=answer) as client:
print(mode, client.protocol_version)
for qty in (1, 5):
result = await client.call_tool("order_book", {"title": "Dune", "quantity": qty})
print(" ", result.content[0].text)
anyio.run(main)
The output on 28 October 2026:
auto 2026-07-28
Ordered 1 x Dune.
asked: Order 5 copies of Dune?
Ordered 5 x Dune with gift wrap.
legacy 2025-11-25
Ordered 1 x Dune.
asked: Order 5 copies of Dune?
Ordered 5 x Dune with gift wrap.
The same tool code served both protocol eras. A second callback that returned ElicitResult(action="decline") produced "The user declined. Nothing was ordered." in both eras too.
Step 4: Add the same confirmation in TypeScript
In TypeScript, the handler returns inputRequired(...) while the answer is missing, and reads it on the retry:
import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/server';
import { acceptedContent, inputRequired, inputResponse, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const confirmSchema = z.object({
ok: z.boolean().meta({ title: 'Place the order?' }),
giftWrap: z.boolean().default(false).meta({ title: 'Add gift wrap?' })
});
function createServer(): McpServer {
const server = new McpServer({ name: 'bookshop', version: '1.0.0' });
server.registerTool(
'order_book',
{
description: 'Order copies of a book. Asks the user to confirm orders of more than 2 copies.',
inputSchema: z.object({ title: z.string(), quantity: z.number().int().min(1) })
},
async ({ title, quantity }, ctx): Promise<CallToolResult | InputRequiredResult> => {
if (quantity <= 2) {
return { content: [{ type: 'text', text: `Ordered ${quantity} x ${title}.` }] };
}
const view = inputResponse(ctx.mcpReq.inputResponses, 'confirm');
if (view.kind === 'elicit' && view.action !== 'accept') {
return { content: [{ type: 'text', text: `The user chose ${view.action}. Nothing was ordered.` }] };
}
const answer = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', confirmSchema);
if (answer === undefined) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: `Order ${quantity} copies of ${title}?`,
requestedSchema: confirmSchema
})
}
});
}
if (!answer.ok) return { content: [{ type: 'text', text: 'The user chose not to order.' }] };
return {
content: [{ type: 'text', text: `Ordered ${quantity} x ${title}${answer.giftWrap ? ' with gift wrap' : ''}.` }]
};
}
);
return server;
}
Points from the TypeScript guide:
acceptedContentreturnsundefinedfor a missing, declined or cancelled answer alike.inputResponsetells those cases apart, so the handler can respect a "no" instead of asking again.- Answers come from the client, so treat them as untrusted. Passing the Zod schema to
acceptedContentvalidates them. - The Zod schema is converted to the spec's restricted form schema. Nested objects and regex patterns throw a
TypeErrorbefore anything is sent.
Driven by a client with an elicitation/create handler, this ran in the modern era on HTTP, and in both eras over stdio. One limit showed up: on a stateless HTTP endpoint, a legacy (2025-era) client could not be asked, and the tool returned an error saying so. The Python deploy guide describes the same trade-off for its stateless legacy mode.
Step 5: Follow the spec's safety rules
The elicitation spec sets firm rules. Servers:
- must not use form mode to request sensitive information such as passwords, API keys or payment credentials;
- must use URL mode for those interactions;
- must handle the user declining or cancelling, or the client failing to process the request;
- must bind elicitation requests to the client and user identity.
Our security risks guide explains why: form answers travel through the client and can land in the model's context.
Try it
- Change the threshold so orders over 10 copies need confirmation.
- Add a
deliveryfield to the form as a string enum with "standard" and "express". - Write a callback that returns
action="cancel"and check the tool's reply. - Add a fourth case: the client does not support elicitation at all. Decide what the tool should do, and test it by removing the callback.
With mcp 2.3.0, a client with no elicitation callback asked for 5 copies. The whole request failed with error -32021: "Client did not declare the form elicitation capability required by resolver..." The tool body never ran. The TypeScript guide documents the same -32021 code for a missing capability. Every confirmation needs a plan for "I can't ask."
Recap
- 2026-07-28 elicitation returns a question from the call; the client retries with the answer.
- Python: a
Resolveparameter whose resolver returnsElicit(...)when it must ask. - TypeScript: return
inputRequired(...), then read withinputResponseandacceptedContent. - Handle accept, decline and cancel, and never collect secrets in a form.
Next step
Lesson 19 turns the Inspector into a repeatable test harness. For the concept in depth, read MCP elicitation. If your server needs confirmations around payments or account changes, eSEOspace MCP development can help scope the flow.
Course navigation
Previous lesson: Lesson 17: Add Prompts.
Next lesson: Lesson 19: Test With the MCP Inspector.
Back to the Learn MCP course outline.
Frequently asked questions
When should a tool ask instead of failing?
Ask when a person must decide something the model should not: confirming a destructive action, choosing between close matches, or approving a cost. If the model could supply the value itself, make it a tool argument instead.
Can I ask for a credit card number with a form?
No. The spec says servers must not use form mode for sensitive information, including payment credentials. Use URL mode, which sends the user to a secure page, so the data never passes through the client or the model.
Does the server have to remember state between the two calls?
No. The client sends the answers back with the retried call. If you need to carry extra state, the 2026-07-28 spec provides an opaque requestState token. The Python SDK seals that token by default, and its deploy guide says multi-instance servers must share the sealing keys.
What if the user never answers?
Then the client never retries, and nothing happens. Design tools so that an unanswered question leaves no half-finished work.
Do all hosts support elicitation?
No. Support varies, and a client only receives questions if it declared the elicitation capability. Check your target hosts, and make sure each tool behaves sensibly when it cannot ask.











