Skip to content

Multi Round-Trip Requests (MRTR)

Legacy MCP (2025-11-25 and earlier) let a server send elicitation/create, sampling/createMessage, or roots/list as JSON-RPC requests to the client, over a session. That works on stdio, but it forces a remote server to keep per-session state and a live back-channel — which breaks the moment you put a load balancer in front of two replicas. Whichever replica gets the retry has to know what the other one asked.

Multi Round-Trip Requests (MRTR) removes the back-channel. The server never asks; it answers with a question, and the client re-issues the original request carrying the answer. Any replica can finish the job because everything it needs is in that retry.

  1. The client sends a normal request — say tools/call.
  2. The server realizes it needs more information and responds with an InputRequiredResult.
  3. The client gathers the requested input (from the user, or elsewhere), then retries the original request with the input attached.
  4. The server now has enough, does the work, and returns the final result.
sequenceDiagram
  participant C as Client
  participant S as Server
  C->>S: tools/call (id 1, params)
  Note over S: needs input
  S-->>C: result resultType input_required (inputRequests, requestState)
  Note over C: request 1 is finished
  Note over C: gather input from the user
  C->>S: tools/call (id 2, same params + inputResponses + requestState)
  Note over S: reconstitute from requestState, run
  S-->>C: result resultType complete
MRTR: two independent requests, one logical operation

Note what the diagram does not show: any state held on the server between step 2 and step 3. Request 1 is finished the moment the server responds. Request 2 is a brand-new, self-contained request.

A result whose resultType is "input_required". It carries up to two fields:

  • inputRequests — a map of server-assigned string keys to request objects the client must fulfil.
  • requestState — an opaque string meaningful only to the server.

A server must include at least one of the two. It may include only requestState (meaning “just retry later, I’ve stashed what I need in here”), or only inputRequests.

{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "input_required",
"inputRequests": {
"pick_region": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Which region should the database live in?",
"requestedSchema": {
"type": "object",
"properties": {
"region": { "type": "string", "enum": ["eu-west-1", "us-east-1", "ap-southeast-1"] }
},
"required": ["region"]
}
}
}
},
"requestState": "v1.eyJuYW1lIjoib3JkZXJzIn0.h2Kq..."
}
}

The values in inputRequests are ordinary MCP request shapes — one of ElicitRequest, CreateMessageRequest (sampling), or ListRootsRequest. The keys are chosen by the server and must be unique within that one response. A single InputRequiredResult may bundle several questions.

On the retry, the client sends inputResponses — the same keys, each mapped to the matching result type (ElicitResult, CreateMessageResult, ListRootsResult):

{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "provision_database",
"arguments": { "name": "orders" },
"inputResponses": {
"pick_region": {
"action": "accept",
"content": { "region": "eu-west-1" }
}
},
"requestState": "v1.eyJuYW1lIjoib3JkZXJzIn0.h2Kq...",
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": { "elicitation": { "form": {} } }
}
}
}

Three rules about the retry:

  • The JSON-RPC id must differ from the first attempt — they are independent requests.
  • The original name and arguments are sent again, unchanged.
  • requestState is echoed byte for byte if the server sent one, and omitted if it did not. The client must never parse, modify, or reason about it.

Only three client requests may receive an InputRequiredResult:

Client requestMay return input_required
tools/callyes
resources/readyes
prompts/getyes
everything else (tools/list, server/discover, …)no

If you find yourself wanting to elicit during tools/list, the design is wrong: listings must not vary per connection or as a side effect of other requests.

  • Never request what the client cannot do. If the client’s io.modelcontextprotocol/clientCapabilities did not declare elicitation, the server must not put an elicitation/create in inputRequests. Check the capabilities on this request — there is no earlier handshake to remember.
  • Never assume the retry will come. The user may decline, the client may give up. Do not pre-allocate resources on the first leg that only the second leg would clean up.
  • Missing input is not an error. If the retry arrives without something you asked for and you still need it, return a new InputRequiredResult asking again — not a JSON-RPC error.
  • Ignore what you did not ask for. Extra keys in inputResponses are ignored.
  • You may loop. A server can return input_required on several successive attempts, asking one thing at a time, until it has what it needs.

Because requestState leaves the server and comes back through the client, a malicious or compromised client can tamper with it. The spec is blunt: if requestState influences authorization, resource access, or business logic, the server must protect its integrity — HMAC it, or seal it with an AEAD — and reject anything that fails verification.

To bound replay, put these inside the protected payload and check them on receipt:

  • the authenticated principal — reject state presented by a different user;
  • a short TTL — reject stale state;
  • an identifier for the originating request — method name plus a digest of the salient params, so state minted for provision_database("orders") cannot be replayed on delete_database("orders").

Those measures bound the window; they do not guarantee single use. If a given state must be consumed at most once (a one-time redemption), enforce that server-side with a store. Integrity protection may be skipped only when tampering can cause nothing worse than the request failing.

You will rarely build InputRequiredResult by hand. Both official SDKs let you write a handler as if the question were synchronous, and split it into the two legs behind the scenes.

import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const server = new McpServer({ name: 'infra', version: '1.0.0' });
server.registerTool(
'provision_database',
{ description: 'Create a database', inputSchema: z.object({ name: z.string() }) },
async ({ name }, ctx) => {
// Looks synchronous. On the wire this returns input_required,
// and the handler is re-run on the retry with the answer available.
const answer = await ctx.mcpReq.elicitInput({
mode: 'form',
message: `Which region should ${name} live in?`,
requestedSchema: {
type: 'object',
properties: { region: { type: 'string', enum: ['eu-west-1', 'us-east-1'] } },
required: ['region'],
},
});
if (answer.action !== 'accept') {
return { content: [{ type: 'text', text: 'Provisioning cancelled.' }] };
}
return { content: [{ type: 'text', text: `Created ${name} in ${answer.content?.region}` }] };
}
);

The Python Resolve form is the era-portable one: it produces a clean two-leg MRTR exchange on modern clients and still works with legacy clients that expect a pushed request. The important mental model either way: your handler may run more than once. Keep side effects after the point where all inputs are known.

  • One question per round when it needs sequencing; many when it doesn’t. inputRequests is a map, so independent questions can travel together and save a round trip.
  • Keep requestState small. It rides through the client twice and into logs. Encode ids and digests, not whole documents.
  • Idempotent retries. The retry re-sends the original arguments. If leg one already did something irreversible, leg two must not do it again — another reason to defer side effects.
What must a client do after receiving an InputRequiredResult with inputRequests?
Which client requests may legitimately receive resultType "input_required"?
What is the client allowed to do with requestState?
Why must a server HMAC or AEAD-protect requestState that affects authorization?