A tool doesn't have to answer with only what the caller gave it. It can stop mid-call, ask the user a question, and finish once the answer comes back. That's elicitation , and it's what a confirmation step or a missing detail the model shouldn't invent both need.
How a server asks
A server can't ask directly. Every interaction in MCP begins with the client, and a server must
never initiate a request of its own: it answers requests, and that's all. So a handler asks by
returning inputRequired(...) in place of a result. The client reads the question, puts
it to the user, and calls your tool a second time with the answer attached. The spec calls that
two-round shape a multi round-trip request, or MRTR.
The shape of a tool that asks
You need one import line for the three helpers:
import { McpServer, inputRequired, acceptedContent, inputResponse } from "@modelcontextprotocol/server"; And the handler takes on a two-branch shape:
// The schema for what you are asking the user, not for the tool's arguments.
const answerSchema = z.object({
fieldName: z.boolean().meta({ title: "Label the user sees" })
});
server.registerTool(
"tool-name",
{
title: "Display Name",
description: "What the tool does",
inputSchema: z.object({}) // nothing the model supplies
},
async (_args, ctx) => {
const view = inputResponse(ctx.mcpReq.inputResponses, "answerKey");
// Round 1: nobody has answered yet, so ask.
if (view.kind === "missing") {
return inputRequired({
inputRequests: {
answerKey: inputRequired.elicit({
message: "The question the user reads",
requestedSchema: answerSchema
})
}
});
}
// Round 2: an answer arrived. undefined means declined, cancelled, or false.
const answer = acceptedContent(ctx.mcpReq.inputResponses, "answerKey", answerSchema);
if (answer?.fieldName !== true) {
return { content: [{ type: "text", text: "Did not do it." }] };
}
// Do the thing.
return { content: [{ type: "text", text: "Did it." }] };
}
); The handler runs once per round and works out where it is purely from what arrived, because nothing on the server survives between rounds. There is no "waiting" state to hold: round one returns a question, round two receives an answer.
inputResponse tells you which round you are in. missing means nobody has answered yet, so ask. Anything else means an answer came back,
so decide.
acceptedContent validates the answer against the same schema you sent,
and returns undefined when the user declined, cancelled, or submitted a false
value. Treating all three alike is what stops the tool asking forever.
Two schemas, doing different jobs. inputSchema is empty because the
model supplies nothing. The schema you pass to requestedSchema describes what you are
asking the user, and .meta({ title }) is the label they actually
read in the dialog.
✏️ Exercise: confirm before clearing
Add a clear-tasks tool to src/stdio-server.ts that wipes the list, but
only after the user says yes:
- An empty
inputSchema. The model passes nothing; everything comes from the user. -
A confirmation schema with one boolean,
confirm, whose.meta({ title })reads as a checkbox label, something likeYes, clear the list. -
Round one: return
inputRequiredwith a message naming how many tasks are about to go and that it cannot be undone. - Round two: on a true answer, empty the array and report how many were cleared. On anything else, change nothing and say so.
Two traps to get right, both of which produce a tool that looks fine and behaves badly:
- Do not re-ask when the answer is no. If you re-issue the request whenever the
answer is not the one you wanted, a decline looks identical to a first call and the tool asks
forever. Only the
missingbranch may ask. - Do not clear on a falsy answer. Check for
=== truerather than truthiness, and let declined, cancelled, andfalseall take the same do-nothing path.
Verify the registration
Elicitation needs a client that can put a dialog in front of you, so the round-trip cannot run
through Inspector's --cli mode, which declares no elicitation capability and is refused
before the question is even sent. What --cli confirms is the half you can check
statically:
npx @modelcontextprotocol/inspector --cli npx tsx src/stdio-server.ts --method tools/list clear-tasks should be listed with an empty inputSchema. If
it declares a confirm argument, you have written a confirmation the model can grant
itself, which is no gate at all. Fix that before chasing a dialog that never appears.
Solution
const confirmSchema = z.object({
confirm: z.boolean().meta({ title: "Yes, clear the list" })
});
server.registerTool(
"clear-tasks",
{
title: "Clear Tasks",
description: "Remove every task from the list, after the user confirms",
inputSchema: z.object({})
},
async (_args, ctx) => {
const view = inputResponse(ctx.mcpReq.inputResponses, "confirm");
if (view.kind === "missing") {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: `Clear all ${tasks.length} tasks? This cannot be undone.`,
requestedSchema: confirmSchema
})
}
});
}
const answer = acceptedContent(ctx.mcpReq.inputResponses, "confirm", confirmSchema);
if (answer?.confirm !== true) {
return { content: [{ type: "text", text: "Nothing cleared." }] };
}
const cleared = tasks.length;
tasks.length = 0;
return { content: [{ type: "text", text: `Cleared ${cleared} tasks.` }] };
}
); Answer a question from your own tool
Restart demo-server the way you did in lesson 3, and ask the assistant to add a couple
of tasks so the list isn't empty.
-
Ask it to clear the list. Your assistant shows a confirmation dialog carrying your
messageand the checkbox fromconfirmSchema. Tick the box, then submit. In Copilot you click both; in Claude Code's terminal UI you press space to tick the box and enter to choose Accept. What comes back? - Ask it to clear the list again, and decline the dialog this time. What comes back?
-
Take the
view.kind === "missing"check out and re-run step 2. What happens, and why?
Note: if step 1 answers "Nothing cleared.", the box wasn't ticked when
the form went back. Submitting an untouched checkbox sends confirm: false, which is a
perfectly valid accepted answer, so your handler correctly takes the same branch a decline takes.
Nothing errors and nothing warns you, which is what makes it worth recognizing: the tool did
exactly what the input told it to.
Check your understanding
Why does removing the "missing" check loop forever?
You delete the `view.kind === "missing"` branch, then decline the confirmation dialog. The tool asks again, and again. What went wrong?