<img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 "> Asking the User for Input

Asking the User for Input

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.

Client Server tools/call (id: 1) InputRequiredResult tools/call (id: 2) original params + inputResponses result
One action by the user, two tools/call requests with different ids, and your handler running once for each.

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 like Yes, clear the list.
  • Round one: return inputRequired with 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:

  1. 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 missing branch may ask.
  2. Do not clear on a falsy answer. Check for === true rather than truthiness, and let declined, cancelled, and false all 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.

  1. Ask it to clear the list. Your assistant shows a confirmation dialog carrying your message and the checkbox from confirmSchema. 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?
  2. Ask it to clear the list again, and decline the dialog this time. What comes back?
  3. 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

Question 1 of 3

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?