MCP in Practice

Lesson 22 of 36

Elicitation I: the form that comes back with a red mark

Book the big room for Thursday

Lesson 1 left a sentence hanging, and here it is again.

The big room could be The Atrium, which has 24 seats and is genuinely the biggest room on the floor, or Studio B, which the second-floor design team have called "the big room" for two years because they never come downstairs. Nothing in the request distinguishes them.

Your server has three choices. It can guess, and be wrong often enough that somebody has to check every booking, which is worse than no booking system. It can fail, and return "ambiguous room" to a model that will simply guess on your behalf and call again. Or it can ask.

Asking is the right answer and it is called elicitation. What makes it interesting is that a stateless protocol has no obvious way to do it — the server is in the middle of handling a request, with no open channel to speak into.

The form that comes back with a red pen mark

You submit a form. Some time later it comes back with one field circled in red and a note: which of these did you mean? You fill in that field and resubmit the same form. The office does not phone you mid-processing; they return what you sent, marked up, and wait for you to send it again.

That is exactly the mechanism, and the analogy is not decorative — "fill this in and resubmit the same form" is literally the protocol's retry semantics. For a beginner it makes the flow obvious without a diagram. For an engineer it names the design immediately: the server is not blocked waiting on the client, the interaction is a fresh request rather than a held connection, and nothing needs to be remembered in between.

MCP calls this pattern MRTR — Multi Round-Trip Requests.

The server returns a question, the client collects the answer, and the client calls the tool again
The server returns a question, the client collects the answer, and the client calls the tool again

What actually goes over the wire

The client calls the tool, as normal:

json
{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "tools/call",
  "params": {
    "name": "book_room",
    "arguments": {"day": "2026-09-17", "hour": 9, "room_hint": "the big room"},
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {"elicitation": {}}
    }
  }
}

The server cannot proceed. Instead of an answer it returns an InputRequiredResult carrying the question:

json
{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "room": {
        "method": "elicitation/create",
        "params": {
          "mode": "form",
          "message": "'the big room' matches more than one room. Which did you mean?",
          "requestedSchema": {
            "type": "object",
            "properties": {
              "room_id": {
                "type": "string",
                "enum": ["atrium", "studio-b", "nook"],
                "description": "The room to book. The Atrium seats 24, Studio B 12, The Nook 4."
              }
            },
            "required": ["room_id"]
          }
        }
      },
      "requestState": "..."
    }
  }
}

This is a successful response, not an error. resultType is input_required rather than complete, which is why Lesson 6 insisted you notice that field.

The client renders a form from requestedSchema, the user picks, and the client calls the tool again — same arguments, plus the answers and any requestState the server sent:

json
{
  "jsonrpc": "2.0",
  "id": 8,
  "method": "tools/call",
  "params": {
    "name": "book_room",
    "arguments": {"day": "2026-09-17", "hour": 9, "room_hint": "the big room"},
    "inputResponses": {"room": {"action": "accept", "content": {"room_id": "studio-b"}}},
    "requestState": "...",
    "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28"}
  }
}

New id, new request. The server now has what it needs and returns the booking.

Why it works this way

If you have read an older tutorial, elicitation was a server-to-client push: the server sent an elicitation/create request down the open connection while holding the original call. That design is gone, replaced by the return-and-retry above, because a stateless protocol has no open channel for a server to speak into — the same reason sampling, roots and protocol logging were deprecated in Lesson 5.

Three things follow, and they are all improvements:

  • Nothing is held. No connection stays open across a human's thinking time, which can be minutes. Timeouts and proxies stop being a problem.
  • The state is explicit. Whatever the server needs to resume rides in requestState and comes back on the retry, rather than living in a process's memory. Any instance can serve the retry. To the client it is opaque — the specification says clients must not inspect, parse or modify it.
  • Refusal is a first-class outcome. The client can answer decline or cancel as easily as accept, and those are different things — declined means the user said no, cancelled means they dismissed the whole operation.

The cost is that a tool handler may run more than once for one logical operation. Handlers must therefore be safe to re-enter, which mostly means doing the irreversible thing after the questions rather than before.

requestState is attacker-controlled

That last point deserves its own heading, because it is the one place MRTR can bite you badly and it is easy to miss.

requestState leaves your server, sits in a client you do not control, and comes back. The specification is blunt about what follows: servers must treat it as attacker-controlled input. If it influences authorization, resource access or business logic, you must protect its integrity — an HMAC or an AEAD-encrypted blob — and reject anything that fails verification.

State that leaves your server and comes back must be integrity-protected and bound against replay
State that leaves your server and comes back must be integrity-protected and bound against replay

Three things belong inside that protected payload, and each closes a specific replay:

  • The authenticated principal, so state minted for one user cannot be presented by another.
  • A short expiry, so a captured blob stops working quickly.
  • An identifier for the originating request — the method name and a digest of its salient arguments — so state issued for "cancel booking bk_1" cannot be replayed onto "cancel booking bk_9".

Those bound the replay window and stop cross-user and cross-request reuse. They do not make the state single-use, so if an operation must happen at most once — a redemption, a payment — enforce that server-side as well.

The reasonable shortcut: if tampering with your requestState can cause nothing worse than the request failing, integrity protection is optional. Storing a room id in it is fine. Storing "this user is an administrator" is not.

The capability that must be declared

Notice "clientCapabilities": {"elicitation": {}} in the request. A server may only ask a question of a client that said it could handle one.

If it is missing and the server needs to ask, the server returns MissingRequiredClientCapabilityError (-32021) naming what it needed. This is the error from Lesson 6, and now you know when it fires.

That is a real constraint rather than a formality: a tool that can only work by asking will not work everywhere. The right design, wherever possible, is a tool that asks only when it must — which is exactly what the next lesson builds.

What to take into the next lesson

Elicitation is return-and-retry, not push: the server answers with InputRequiredResult, the client collects input and calls the tool again with inputResponses, and nothing is held open in between. Handlers must be safe to re-enter, and a client that never declared the elicitation capability cannot be asked. Next: the SDK turns all of this into a parameter type.

← Previous