technique

Multi round-trip requests

How a stateless server asks for more input mid-call: returning input_required, the client gathering answers through elicitation, and the retry that completes the request.

Before this

This page assumes you are comfortable with:

Why you need this

Some tool calls cannot finish with only the arguments the model (the language model inside the host) supplied. A delete_note tool should ask the person "are you sure?" before deleting. A book_table tool may need a phone number the model does not know. The server (the program offering the tool) needs to pause, ask the person through the host (the app they are using), and continue. In the 2026-07-28 protocol, where no request may depend on an earlier one, that pause has a specific shape: the multi round-trip request. It belongs to stage 2, designing the surface, because deciding when a tool asks is part of the tool's design.

The idea

In plain words: instead of answering, the server answers "I need this first", the client (the host's connection to that server) gets the answer from the person, and then sends the whole original request again with the answer attached.

The precise version, from the Multi Round-Trip Requests section of the specification:

  1. The client sends a request, say tools/call, with some JSON-RPC id.
  2. The server returns a result with "resultType": "input_required". This is an InputRequiredResult. It may carry:
    • inputRequests: a map from keys the server chooses to requests for the client, such as elicitation/create.
    • requestState: an opaque string the client must echo back unchanged. It must carry at least one of the two.
  3. The client fulfils every input request. For elicitation/create, that means showing the person a form or a link and recording what they did.
  4. The client sends the original request again, same method and same arguments, with a new id, plus inputResponses (the answers, under the same keys) and the exact requestState.
  5. The server, which remembered nothing in between, rebuilds its context from the retry and either completes ("resultType": "complete") or asks again.

Only tools/call, prompts/get, and resources/read may return input_required. A server may only ask for what the client declared it supports in io.modelcontextprotocol/clientCapabilities in the request's _meta; a client that declares no elicitation capability will never be shown a form.

Elicitation: form mode and URL mode

Elicitation is the server asking the person for input. It has two modes:

Mode What the person sees What the client learns Use it for
form A form built from requestedSchema The values the person typed Confirmations, choices, a missing date
url A link to open in a browser, after consent Only that the person agreed to open it Passwords, API keys, payments, signing in to a third party

Form schemas are deliberately small: a flat object whose properties are strings, numbers, booleans, or enums. The specification forbids form mode for sensitive data such as passwords, API keys, access tokens, or payment details; those go through URL mode so they never pass through the client or the model. After a URL-mode accept, the server cannot assume the out-of-band step is done. On the retry it checks, and asks again if it is not.

The person can answer in three ways, and the server must handle each:

action Meaning content
accept Submitted the form (or agreed to open the URL) The form values; absent for URL mode
decline Explicitly said no Usually absent
cancel Closed the dialog without choosing Usually absent

requestState is attacker-controlled

requestState lets a server carry its own context (which question it asked, what it computed so far) across the retry without storing anything. But it travels through the client, and a malicious or buggy client can change it. The specification says servers must treat it as attacker-controlled input, and if it influences authorization, resource access, or business logic, must protect its integrity with a message authentication code such as HMAC or with authenticated encryption (AEAD), and must reject state that fails the check. It also recommends sealing inside the state the authenticated user, a short expiry, and which request it belongs to, so a token cannot be replayed by another user or on another call. These measures limit replay but do not make a token single-use; if something must happen at most once, the server has to enforce that itself.

Why not just send the server's question as a request?

Older servers (2025-11-25 and earlier) sent elicitation/create as a separate request from server to client while the tool call was still open, which needed a live connection and a server that remembered the paused call. With MRTR, any copy of the server can handle the retry, because everything it needs is in the retry itself. That is what lets a server run as stateless functions behind a load balancer.

Worked example

A notes server whose delete_note tool asks for confirmation, using the official Python SDK. The SDK's Resolve marker fills a parameter by running another function first; if that function returns Elicit(message, schema), the SDK answers the call with an InputRequiredResult and only runs the tool body once the answer arrives. The SDK also seals requestState for you with AES-256-GCM, an authenticated encryption scheme, under a key generated when the process starts.

# delete_note.py
import asyncio
import json
from typing import Annotated

from pydantic import BaseModel, Field

from mcp import Client
from mcp.server import MCPServer
from mcp.server.elicitation import AcceptedElicitation, ElicitationResult
from mcp.server.mcpserver.exceptions import ToolError
from mcp.server.mcpserver.resolve import Elicit, Resolve
from mcp_types import ElicitResult, ToolAnnotations

NOTES = {"7": "Buy flour, eggs, and milk.", "12": "Lab report due Monday.", "15": "Call the dentist."}
server = MCPServer("notes")

class Confirm(BaseModel):
    confirm: bool = Field(description="Check to delete the note for good.")

def ask_to_confirm(note_id: str) -> Elicit[Confirm]:
    """Runs before the tool body. Returning Elicit means: ask the user first."""
    if note_id not in NOTES:
        raise ToolError(f"No note with id {note_id}")
    return Elicit(f"Delete note {note_id} ('{NOTES[note_id]}')? This cannot be undone.", Confirm)

@server.tool(annotations=ToolAnnotations(destructive_hint=True, idempotent_hint=True))
def delete_note(
    note_id: str,
    answer: Annotated[ElicitationResult[Confirm], Resolve(ask_to_confirm)],
) -> str:
    """Delete one note by id. Asks the user to confirm before deleting."""
    if isinstance(answer, AcceptedElicitation) and answer.data.confirm:
        del NOTES[note_id]
        return f"Deleted note {note_id}."
    return f"Kept note {note_id}: the user did not confirm."

async def on_elicit(context, params):
    print("client shows the user:", params.message)
    return ElicitResult(action="accept", content={"confirm": True})

async def by_hand(client, note_id, response, state=None):
    first = await client.session.call_tool("delete_note", {"note_id": note_id}, allow_input_required=True)
    replies = {key: response for key in first.input_requests}
    return first, await client.session.call_tool(
        "delete_note", {"note_id": note_id}, input_responses=replies,
        request_state=state or first.request_state, allow_input_required=True)

async def main():
    async with Client(server, elicitation_callback=on_elicit) as client:
        print("model sees:", json.dumps((await client.list_tools()).tools[0].input_schema["properties"]))
        yes = ElicitResult(action="accept", content={"confirm": True})
        first, second = await by_hand(client, "7", yes)
        print(json.dumps(first.model_dump(by_alias=True, exclude_none=True, mode="json"), indent=2))
        print(json.dumps(second.model_dump(by_alias=True, exclude_none=True, mode="json"), indent=2))
        try:
            await by_hand(client, "12", yes, state="v1.forged-by-the-client")
        except Exception as exc:
            print("tampered:", exc.error.code, exc.error.message)
        _, kept = await by_hand(client, "15", ElicitResult(action="decline"))
        print("declined:", kept.content[0].text)
        done = await client.call_tool("delete_note", {"note_id": "12"})
        print("automatic:", done.content[0].text, "| left:", NOTES)

asyncio.run(main())

by_hand drives the two rounds itself through client.session so you can see each message; client.call_tool at the end does the same thing automatically, calling on_elicit in place of a real dialog. Running python delete_note.py printed model sees: {"note_id": {"title": "Note Id", "type": "string"}}: the answer parameter is not part of the tool's input schema, so the model cannot fill in the confirmation itself.

Here is the full sequence for note 7 as it travels on the wire. The request envelopes are written out by hand; the two results are the program's real output, with _meta.io.modelcontextprotocol/serverInfo removed and the roughly 360-character requestState shortened.

1. Client to server. The client declares form elicitation.

{
  "jsonrpc": "2.0",
  "id": 4,
  "method": "tools/call",
  "params": {
    "name": "delete_note",
    "arguments": { "note_id": "7" },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": { "elicitation": { "form": {} } }
    }
  }
}

2. Server to client. Nothing was deleted yet.

{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "__main__:ask_to_confirm": {
        "method": "elicitation/create",
        "params": {
          "mode": "form",
          "message": "Delete note 7 ('Buy flour, eggs, and milk.')? This cannot be undone.",
          "requestedSchema": {
            "properties": {
              "confirm": { "description": "Check to delete the note for good.", "title": "Confirm", "type": "boolean" }
            },
            "required": ["confirm"],
            "type": "object"
          }
        }
      }
    },
    "requestState": "v1.rf9o87XtBfvRGAZ9whOZhtOa..."
  }
}

The key __main__:ask_to_confirm is the SDK's name for the resolver (module and function name). The v1. prefix marks the SDK's sealed format; the rest is encrypted, so the client cannot read it.

3. The host shows the person a dialog with the message and one checkbox. They check it and press OK.

4. Client to server, retry. New id, same name and arguments, plus the answer and the state.

{
  "jsonrpc": "2.0",
  "id": 5,
  "method": "tools/call",
  "params": {
    "name": "delete_note",
    "arguments": { "note_id": "7" },
    "inputResponses": {
      "__main__:ask_to_confirm": { "action": "accept", "content": { "confirm": true } }
    },
    "requestState": "v1.rf9o87XtBfvRGAZ9whOZhtOa...",
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": { "elicitation": { "form": {} } }
    }
  }
}

5. Server to client. The tool body ran.

{
  "jsonrpc": "2.0",
  "id": 5,
  "result": {
    "content": [{ "type": "text", "text": "Deleted note 7." }],
    "structuredContent": { "result": "Deleted note 7." },
    "isError": false,
    "resultType": "complete"
  }
}

The rest of the output:

tampered: -32602 Invalid or expired requestState
declined: Kept note 15: the user did not confirm.
client shows the user: Delete note 12 ('Lab report due Monday.')? This cannot be undone.
automatic: Deleted note 12. | left: {'15': 'Call the dentist.'}

The forged state was rejected with a protocol error before the tool ran; the server's own log recorded the real reason (requestState rejected on tools/call: malformed) while the client got only a fixed message. The decline reached the tool body, which kept the note.

In a server's life

  • Design the surface (stage 2). Decide which tools need a confirmation or a missing value, and whether that input is sensitive enough for URL mode.
  • Build and connect (stage 3). A client must declare elicitation and handle the loop; Building a client shows the client side, and The host loop where confirmation fits among model turns.
  • Secure it (stage 4). A confirmation step is a defense against a model steered by injected text; see Security threats and defenses.
  • Test and ship (stage 5). The SDK's default key lives only in one process. If several copies of the server sit behind a load balancer, a retry can reach a copy that cannot open the state, so they must share a key; see Deploying remote servers.

Common mistakes

  • Reusing the JSON-RPC id on the retry. Logs and traces show two different requests under one id, and anything that matches responses to requests by id can attach a result to the wrong one. The specification requires a new id because the retry is an independent request.
  • Trusting unsealed requestState. A server that base64-encodes {"approved": false} and later reads it back will happily read {"approved": true} from a tampered client. Seal it, or keep anything that matters out of it.
  • Asking for a password in form mode. The secret passes through the client, its logs, and possibly the model's context. Use URL mode.
  • Treating decline and cancel as errors. The model is told the tool failed and tries again, asking the person the same question in a loop. Return a normal result that says what happened.
  • Assuming the client will retry. The person may walk away. Never hold a lock or half-finished change waiting for a retry that may not come.
  • Asking a client that cannot answer. The client never declared elicitation, so the server must not ask. Calling delete_note from a client with no elicitation callback got the protocol error -32021 (Client did not declare the form elicitation capability required by resolver 'delete_note:ask_to_confirm') instead of a form nobody would see.

Cost

Each round is a full request and response, so a call with one question costs two round trips plus however long the person takes to answer, which dominates everything else. The server's work is repeated: the SDK re-runs resolvers on every round, so keep them cheap and put slow work in the tool body, which runs once. requestState adds its own size to both messages, 362 characters in a measured run here, growing with what the server stores in it. Sealing and opening it is one symmetric encryption and one decryption, small next to a network round trip. The SDK's client gives up after 10 rounds by default, which bounds a server that keeps asking. Operationally, MRTR saves the cost that matters most at scale: no per-call memory on the server and no sticky routing, so any instance can answer any retry. The engineering cost is the key management that makes that true across instances.

Going further

  • The Multi Round-Trip Requests and Elicitation sections of the 2026-07-28 specification, including the URL-mode phishing discussion.
  • Authenticated encryption (AES-GCM) and HMAC, to understand what "sealing" guarantees and what it does not.
  • Key rotation: sealing with a new key while still accepting the old one for one expiry period.
  • Sampling inside inputRequests, where the server asks the client's model for a completion, and why the 2026-07-28 revision deprecates it.

Leads to

Back to Building and maintaining MCP servers