Guide · AI and GenAI
MCP Multi Round Trip Requests: How MRTR Replaces Server Callbacks
19 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- MCP multi round trip requests (MRTR) let an MCP server ask for more input in the middle of a call. MCP, the Model Context Protocol, is an open standard that lets AI apps call tools on outside servers.
- The server returns an input_required result, a reply that lists its questions, and the client retries the same call with the answers. In the 2026-07-28 specification, this replaced the old callback, where the server sent its own question and held the call open.
- The retry carries everything needed, including requestState, an opaque string only the server understands. So any copy of the server, called a replica, can finish the call behind a load balancer that simply takes turns.
- requestState passes through the client, so the spec says to treat it as attacker input. When it affects access or business logic, protect it with HMAC (a keyed tag that proves it was not changed) or AEAD (encryption that also detects changes).
- Beginners can stop after the section on running stateless. Engineers then get a tested gotcha: the official Python SDK seals requestState with a key each process makes for itself, so retries on other replicas fail until replicas share one.
What goes wrong when an MCP server has to stop and ask the user?
The server ends up holding an unfinished call open while a human decides. If that server restarts, or the answer reaches a different copy of it, the call is lost. MCP multi round trip requests (MRTR), added in the 2026-07-28 specification, remove that waiting.
MCP, the Model Context Protocol, is an open standard that lets an AI app call tools and read data on outside servers. The app runs an MCP client, and the two sides talk in JSON-RPC, a simple request and response message format.
Picture a common setup. A payments company's support team uses an AI assistant to handle complaints. The assistant's MCP client calls the company's own MCP server, which has a tool named refund_payment. The setup is made up, but the problem is real.
The team's rule is simple: any refund above ₹5,000 needs a human support agent to click Confirm before money moves. The server runs as three identical copies, called replicas, behind a load balancer that spreads requests across them.
Under the older MCP specification, 2025-11-25, confirmation worked like a phone call on hold. The server sent its question to the client in the middle of the tool call, then waited for the answer.
Now picture a deploy while 14 confirmations are waiting (made up). The replicas holding those calls restart, so the agents' clicks reach nothing. Each refund has to start again from the beginning.
What are MCP multi round trip requests?
MCP multi round trip requests, or MRTR, let a server answer a call with a request for more input instead of a final result. The client gathers that input and sends the same call again with the answers.
Think of a passport office counter. The clerk does not keep you standing at the window while you fetch a missing document. She hands back your file with a stamped slip listing what is missing. You return with both, and any free counter finishes your case.
In MCP terms, the result that asks for more input is an InputRequiredResult. The list of missing things is inputRequests, and the stamped slip is requestState, a string only the server understands. The answers the client brings back go in inputResponses.
Where the picture breaks: a real clerk can phone the first counter to check your slip. MRTR does not need replicas to ask each other anything, so the slip itself must carry everything and prove it is genuine.
Here is one refund under MRTR. The first call and the retry land on different replicas.
A refund confirmation with MRTR
Drag to exploreNotice that the first call ends at the second box. Nothing waits on a replica while the agent decides.
Diagram connections:
- tools/call, id 41 to InputRequiredResult (Replica B answers)
- InputRequiredResult to Confirm dialog (First call is now over)
- Confirm dialog to Agent clicks Confirm
- Agent clicks Confirm to tools/call retry, id 42
- tools/call retry, id 42 to Complete result (Replica C answers)
- tools/call, id 41: Client → Server: refund ₹7,500 for order 88213
- InputRequiredResult: Server → Client: refund_ok question plus requestState
- Confirm dialog: Client → Agent: Refund ₹7,500 to order 88213?
- Agent clicks Confirm: Agent → Client: action accept, confirm true
- tools/call retry, id 42: Client → Server: inputResponses plus same requestState
- Complete result: Server → Client: resultType complete, refund made
What just happened? The first call ended as soon as the server sent its question, so no replica waited for the agent. Replica C checked the state, read the answer and made the refund.
Why did the old MCP callback flow break behind a load balancer?
Under 2025-11-25, on Streamable HTTP, MCP's HTTP transport, the server could send its own elicitation/create request on the stream it opened for the tool call. The client's answer came back as a separate HTTP POST.
That waiting call lived in one replica's memory, so the answer had to reach that same replica. The MRTR page names the two usual fixes it removes: shared storage across server instances, or stateful load balancing.
Stateful, or sticky, load balancing pins a client to one replica, for example by its Mcp-Session-Id header. It works until that replica restarts or is taken out of service.
The same refund with the old callback
Drag to exploreNotice that every box after the first depends on replica A staying alive and receiving the answer.
Diagram connections:
- tools/call, id 7 to elicitation/create
- elicitation/create to Confirm dialog (Call 7 still open)
- Confirm dialog to Agent clicks Confirm
- Agent clicks Confirm to Answer as a new POST
- Answer as a new POST to Tool result (Lost if A restarted)
- tools/call, id 7: Client → Replica A: refund ₹7,500, stream held open
- elicitation/create: Replica A → Client: server's own request, then waits
- Confirm dialog: Client → Agent: Refund ₹7,500 to order 88213?
- Agent clicks Confirm: Agent → Client: accept, a minute later (made up)
- Answer as a new POST: Client → Replica A: must reach the waiting replica
- Tool result: Replica A → Client: only if A is still alive
| Point | Old callback (2025-11-25) | MRTR (2026-07-28) |
|---|---|---|
| Who sends the question | Server sends elicitation/create itself | Server returns it inside an input_required result |
| First call while waiting | Stays open until the user answers | Ends at once |
| Where waiting state lives | One replica's memory | requestState, carried by the client |
| Load balancer needs | Sticky sessions or shared storage | Plain round-robin |
| A replica restarts | Waiting call and answer are lost | Another replica takes the retry, if replicas share the state key |
What does an input_required result look like on the wire?
It is an ordinary JSON-RPC result whose resultType is input_required. Every 2026-07-28 result carries resultType, and a finished one says complete. Here is the server's reply for order 88213, with requestState shortened.
{
"jsonrpc": "2.0",
"id": 41,
"result": {
"resultType": "input_required",
"inputRequests": {
"refund_ok": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Refund ₹7,500 to order 88213?",
"requestedSchema": {
"type": "object",
"properties": {
"confirm": {
"type": "boolean"
}
},
"required": [
"confirm"
]
}
}
}
},
"requestState": "7b22657870223a…9f2c41"
}
}The key refund_ok is a name the server picks, and it must be unique within that request. Each value must be one of three request types: elicitation asks the user, sampling asks the client's AI model, and roots asks which folders to use.
Elicitation is the MCP feature for asking the user a question through the client. Form mode sends a small JSON Schema, a description of the form's fields, here one yes or no field. The agent's click comes back on the retry.
{
"jsonrpc": "2.0",
"id": 42,
"method": "tools/call",
"params": {
"name": "refund_payment",
"arguments": {
"order_id": "88213",
"amount": 7500
},
"inputResponses": {
"refund_ok": {
"action": "accept",
"content": {
"confirm": true
}
}
},
"requestState": "7b22657870223a…9f2c41"
}
}Both examples leave out _meta, a metadata field inside the request, for brevity. Every 2026-07-28 request must carry the protocol version and client capabilities there. The spec also sets these rules:
- The retry must use a new JSON-RPC id, the number that pairs a reply with its request. Here it is 42 instead of 41.
- The client must echo requestState exactly, without reading or changing it. If none came, the retry sends none.
- The result must include inputRequests, requestState or both. Only tools/call, prompts/get and resources/read may return input_required.
- The server must not ask for anything the client did not declare in its capabilities, the features it says it supports.
- If a needed answer is missing, the server should ask again rather than return an error. This one is a recommendation, not a strict rule.
The capability rule is the server's job. In our test, when a tool built the result itself, Python SDK 2.3.0 sent an elicitation question to a client that declared no elicitation support. So check before you ask, or use the SDK's Elicit dependency form. There you mark a parameter of the tool function as the answer to a question, and the SDK asks it and checks for you.
Why does MRTR let MCP servers run stateless behind ordinary load balancers?
Each retry carries everything needed, so no replica has to remember anything between calls.
The rest of the release points the same way. The initialize handshake, which opened a session, and the Mcp-Session-Id header are gone. Each request now carries its protocol version and client capabilities in _meta.
The release blog says any request can now land on any server instance behind a plain round-robin load balancer, without shared storage. Round-robin simply sends each request to the next replica in turn.
The refund server after MRTR
Drag to exploreNotice what the replicas share: one state key and one database, never a waiting call.
Diagram connections:
- Support assistant app to Load balancer (First calls and retries)
- Load balancer to MCP replica 1
- Load balancer to MCP replica 2
- Load balancer to MCP replica 3
- Shared state key to MCP replica 1 (Seal and verify state)
- Shared state key to MCP replica 2
- Shared state key to MCP replica 3
- MCP replica 1 to Refunds database (Claim refund id once)
- MCP replica 2 to Refunds database
- MCP replica 3 to Refunds database
- MCP replica 1 to Payment gateway (Refund API call)
- MCP replica 2 to Payment gateway
- MCP replica 3 to Payment gateway
- Support assistant app: MCP client that shows confirm dialogs to agents
- Load balancer: Plain round-robin, no sticky sessions
- MCP replica 1: Same code; keeps nothing between calls
- MCP replica 2: Can finish a call that replica 1 started
- MCP replica 3: Restarted in a deploy, lost nothing
- Shared state key: One secret from the secret store, on every replica
- Refunds database: Records each refund once, by its refund id
- Payment gateway: Sends the money back to the customer
Stateless means the protocol keeps no memory between calls. Your server can still use a database. The tools page suggests explicit handles, ids your tool returns and takes back as arguments, for state across calls.
How do you protect requestState from tampering?
Seal it, verify it on every retry, and reject anything that fails. The spec says to treat requestState as attacker-controlled input, because it passes through the client.
If requestState affects authorisation, data access or business logic, the server must protect its integrity, for example with HMAC or AEAD. HMAC is a keyed hash, a short tag only the holder of the secret key can produce. AEAD, authenticated encryption, hides the contents and also detects tampering.
To limit replay, the spec recommends three things inside the protected state: the authenticated user, a short expiry and an identifier for the original request. The server checks each one on receipt.
A hand-rolled version with HMAC
If your SDK does not seal state for you, this is the whole job. First, the refund tool asks for confirmation and signs its state.
import hashlib, hmac, json, time
SECRET = b"load-me-from-a-secret-store" # same key on every replica
TTL = 300 # seconds
def args_digest(args):
return hashlib.sha256(json.dumps(args, sort_keys=True).encode()).hexdigest()
def sign_state(payload):
body = json.dumps(payload, sort_keys=True).encode()
tag = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
return body.hex() + "." + tag
def ask_to_confirm(user_sub, args):
state = {"sub": user_sub, "exp": int(time.time()) + TTL,
"req": "tools/call:refund_payment:" + args_digest(args)}
question = {"method": "elicitation/create", "params": {
"mode": "form", "message": f"Refund ₹{args['amount']:,} to order {args['order_id']}?",
"requestedSchema": {"type": "object", "required": ["confirm"],
"properties": {"confirm": {"type": "boolean"}}}}}
return {"resultType": "input_required", "inputRequests": {"refund_ok": question},
"requestState": sign_state(state)}The req field joins the method, the tool name and a hash, a short fingerprint, of the arguments. So a state made for a ₹7,500 refund fails on a ₹75,000 one. The expiry is 300 seconds, and sub is the user id from authorisation.
The second half runs on whichever replica gets the retry.
def verify_state(token, user_sub, args):
body_hex, _, tag = token.partition(".")
try:
body = bytes.fromhex(body_hex)
except ValueError:
raise PermissionError("requestState is not valid")
good_tag = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(good_tag.encode(), tag.encode()):
raise PermissionError("requestState was changed")
state = json.loads(body)
if state["sub"] != user_sub:
raise PermissionError("state belongs to another user")
if int(time.time()) not in range(state["exp"] - TTL - 60, state["exp"]): # 60 s clock slack
raise PermissionError("state is outside its time window")
if state["req"] != "tools/call:refund_payment:" + args_digest(args):
raise PermissionError("state is for a different request")
return statehmac.compare_digest is designed to resist timing attacks, where someone guesses a tag by measuring tiny delays. The range check rejects expired state and state dated in the future, with 60 seconds of slack for replica clocks that disagree.
After the checks, the handler reads refund_ok from inputResponses. Accept with confirm true means the refund goes ahead.
If the agent declines or cancels, the tool returns a normal complete result saying no money moved. The elicitation page says servers must handle both cases. If the answer is missing, the server asks again.
One limit to keep in mind: the state proves the server asked the question. It cannot prove a human answered, because the client relays the answer.
What does the official Python SDK do with requestState by default?
Its high-level MCPServer class seals every outgoing requestState for you, using a key it generates when the process starts. That default suits one process, but it breaks retries across replicas and restarts.
We ran exactly this on SDK 2.3.0 in October 2026. Replica B answered HTTP 400 with JSON-RPC error -32602, "Invalid or expired requestState". Its log said: unknown key.
Restarting replica A between the first call and the retry gave the same error. The SDK documentation states this too: the default key lives and dies with the process.
What the SDK seals and checks
- With its built-in codec it encrypts the state with AES-256-GCM, an AEAD cipher, so the client can neither read nor change it.
- It binds the method, the tool name and a digest of the arguments, so changing the amount gets the same -32602 error.
- It also records an audience, the server's name, and the logged-in user and app whenever the request carries a login token.
- Each round expires after 600 seconds by default, set with ttl. A new round gets a fresh expiry.
- It does not make a state single use. In our test, the same valid state completed the refund twice.
How to share the key across replicas
Pass RequestStateSecurity with a shared secret of at least 32 bytes, and keep the same server name on every replica. This version of the refund tool ran on SDK 2.3.0. It also refuses clients that cannot show a form, with error -32021.
import json, os
import uvicorn
from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity
from mcp.shared.exceptions import MCPError
from mcp_types import (MISSING_REQUIRED_CLIENT_CAPABILITY, CallToolResult,
InputRequiredResult, TextContent)
# Every replica loads the same secret (32 bytes or more) and uses the same name.
security = RequestStateSecurity(keys=[os.environ["MCP_STATE_KEY"]], ttl=300)
app = MCPServer("refunds", request_state_security=security)
def ask_to_confirm(order_id, amount):
question = {"method": "elicitation/create", "params": {
"mode": "form", "message": f"Refund ₹{amount:,} to order {order_id}?",
"requestedSchema": {"type": "object", "required": ["confirm"],
"properties": {"confirm": {"type": "boolean"}}}}}
return InputRequiredResult.model_validate({
"inputRequests": {"refund_ok": question},
"requestState": json.dumps({"step": "confirm", "order": order_id})})
@app.tool()
def refund_payment(order_id: str, amount: int, ctx: Context):
"""Refund a payment after a support agent confirms it."""
answer = (ctx.input_responses or {}).get("refund_ok")
state = json.loads(ctx.request_state) if ctx.request_state else {}
if state.get("step") != "confirm" or answer is None: # not our question: ask
caps = ctx.client_capabilities
el = caps.elicitation if caps else None
if el is None or (el.form is None and el.url is not None): # cannot show a form
raise MCPError(code=MISSING_REQUIRED_CLIENT_CAPABILITY, message="Client cannot show a form",
data={"requiredCapabilities": {"elicitation": {"form": {}}}})
return ask_to_confirm(order_id, amount)
if answer.action == "accept" and (answer.content or {}).get("confirm") is True:
text = f"Refunded ₹{amount:,} for order {order_id}" # claim the refund id once first
else:
text = f"No refund made ({answer.action})"
return CallToolResult(content=[TextContent(type="text", text=text)])
port = int(os.environ.get("PORT", "8811"))
uvicorn.run(app.streamable_http_app(json_response=True), host="127.0.0.1", port=port)keys is a list so you can rotate. The first key seals new state, and every key in the list still verifies older state. Add the new key everywhere before you move it to the front.
This hand-built version serves 2026-07-28 clients only. In our test, a 2025-11-25 client got error -32603, "Handler returned an invalid result".
If older clients still call you, write the question with the SDK's Elicit dependency form instead. In our test, it asked older clients the old way and refused new clients that cannot show a form. The old way needs streamed responses (an event stream per call), the SDK default. With json_response=True, which sends plain JSON replies, it failed.
How to check it worked
Start two replicas with the same MCP_STATE_KEY on different ports. Send the first call to one and the retry to the other. These are the results we got on SDK 2.3.0:
| What you send | Default key per process | Shared key on every replica |
|---|---|---|
| First call to replica A | HTTP 200, resultType input_required, state starts with v1. | Same |
| Retry with the answer to replica B | HTTP 400, error -32602, Invalid or expired requestState | HTTP 200, resultType complete, refund made |
| Retry to replica A after it restarts | HTTP 400, error -32602 | HTTP 200, resultType complete |
| Same state, amount changed to 75000 | HTTP 400, error -32602 | HTTP 400, error -32602 |
| Same valid state sent a second time | HTTP 200 on the same replica | HTTP 200: no single use built in |
| First call from a client that cannot show a form | HTTP 400, error -32021, from the tool's own check | Same |
How do you move an MCP server from callbacks to MRTR?
Treat it as a change in shape, not a new feature. For each tool that asks the user something, follow these steps.
Upgrade the SDK
Use an SDK version that speaks 2026-07-28. For Python that is 2.0.0 or later. The 1.x line, latest 1.30.0 (7 September 2026), tops out at 2025-11-25.
Find every callback
Look for elicitation/create, sampling/createMessage or roots/list sent mid-tool. Each becomes an input_required result. Sampling and roots are deprecated, so consider removing those calls instead.
Split each tool into two passes
The first pass returns the question and the state. The second checks the state, reads inputResponses and finishes.
Share the state key
Give every replica the same secret and the same server name. In Python, pass request_state_security; the default key works for one process only.
Make the second pass repeat-safe
Store an idempotency key, a unique id for the action, so a replayed retry cannot refund twice.
Drop sticky sessions for new clients
Remove session pinning for 2026-07-28 traffic. Clients on older revisions still need pinning, plus a tool that can ask them the old way, until they upgrade.
Test across replicas
Send the first call to one replica and the retry to another. Also test tampered, expired, replayed and declined cases.
In the made-up refund setup, the next deploy restarted all three replicas, and no confirmation was lost.
What else changed in the MCP 2026-07-28 specification around MRTR?
Three nearby changes matter if your server asks users for input.
- notifications/elicitation/complete and the elicitationId field of URL mode are removed. The client learns the outcome by retrying instead.
- Roots, Sampling and Logging are deprecated. They keep working for at least twelve months, but new implementations should not adopt them.
- Tasks, for long-running work, moved out of the core protocol into an official extension named io.modelcontextprotocol/tasks.
URL mode elicitation, added in 2025-11-25, still carries a note that its design may change in future revisions.
How do you explain MCP multi round trip requests in a system design interview?
Frame it as a move from callbacks to retries, then cover scaling and security.
State the problem
A tool sometimes needs a human answer midway. Holding the call open ties it to one replica's memory.
Describe the retry
The server returns input_required with questions and a requestState. The client asks the user and retries with inputResponses and a new id.
Draw the architecture
Show a round-robin load balancer, identical replicas and one shared state key. The retry can land on any replica.
Secure the state
Seal requestState, bind it to the user, an expiry and the request, and enforce single use for money moves.
Cover failures
Handle decline, cancel and missing answers without throwing errors. Mention that a per-process key breaks retries after restarts.
Give rough numbers
Assume a made-up 1,200 refunds a day, and assume 10% need a human. That is about 120 extra round trips a day, tiny next to human thinking time.
Questions people ask
- Does MRTR mean my MCP server cannot keep any state?
- No, only the protocol stopped keeping state for you. Your server can still use a database, like the refunds table in the example. For state across several calls, the tools page suggests returning an explicit handle and taking it back as an argument later.
- Can a server ask for more than one thing at once?
- Yes. inputRequests is a map, so one result can hold several questions under different keys. The client answers each under the same key in inputResponses. A server could ask the agent to confirm a refund and pick a reason in one round trip.
- What happens if the user clicks Decline or closes the dialog?
- The client can retry with action set to decline or cancel instead of accept, or it may not retry at all. The elicitation page says servers must handle decline and cancel. In the example, the tool replies with a normal complete result saying no refund was made.
- Why must the retry use a new JSON-RPC id?
- Because the first call is already finished, so the retry is a separate, independent request. The spec says the id must differ between the two. What links them is the repeated method, arguments and requestState, not the id.
- Is elicitation deprecated along with sampling and roots?
- No. The 2026-07-28 release deprecates Roots, Sampling and Logging, but elicitation stays active. What changed is how it travels. The question now rides inside an input_required result, not a server-sent elicitation/create request.
- Do I have to use HMAC, or can I use a JWT?
- Any format works, as long as you protect integrity when the state matters. The spec lists base64 JSON, an encrypted JWT and serialised binary as examples. It names HMAC or AEAD for integrity. Verify on every retry and reject failures.
- Do I need a shared key if I run only one server process?
- Not with the Python SDK's default, as long as it really is one process. The SDK docs say the default suits stdio or exactly one HTTP worker. A restart still invalidates state minted before it, so a shared key is safer for anything that deploys often.
Sources and further reading
- MCP specification 2026-07-28: Multi Round-Trip Requests
- MCP specification 2026-07-28: Key Changes (changelog)
- MCP specification 2026-07-28: Overview (resultType, _meta, statelessness)
- MCP specification 2026-07-28: Elicitation
- MCP specification 2026-07-28: Tools (input required results, stateful tools)
- MCP specification 2025-11-25: Transports (the old Streamable HTTP flow)
- MCP blog: The 2026-07-28 Specification
- MCP Python SDK docs: Multi-round-trip requests
- MCP Python SDK docs: Deploy and scale
- PyPI: mcp 2.3.0