Multi Round-Trip Requests

By the Syncfusion DevTutorials team · Last updated

How servers ask for input in 2026-07-28: input_required results, inputRequests and inputResponses, the client retry loop, requestState and how to protect it, round limits, and the Tasks extension.

Think about ordering a custom cake by phone. You say "a birthday cake for Saturday." The baker asks for the flavor and the name for the top. You answer. Then the baker asks if you want to book it, and you say yes. Only then is the order placed.

The baker did not call you later out of the blue. Each question came as part of the same order conversation, and each answer was sent back in the next step. Some MCP tool calls need the same kind of back-and-forth. This pattern is called a Multi Round-Trip Request, or MRTR.

The main idea: when a server needs more information before it can finish, it answers with resultType: "input_required" and one or more inputRequests. The client gathers the answers, then sends the same logical request again with inputResponses and the server's requestState. This repeats until the server returns a final result such as "complete".

A Real-World Example

Maya's customer wants to return a broken lamp. The assistant calls Brightgrid's book_return tool.

  1. Round 1: the server answers input_required with a form: "Reason? Email for the label? How many boxes? Pickup date?"
  2. The host shows the form. Maya enters the answers.
  3. Round 2: the client calls book_return again with those answers. The server checks them and then asks a second question: "Book a pickup on 12 October for 2 boxes?"
  4. Maya confirms.
  5. Round 3: the client sends the same request again with the confirmation. The server books the pickup and returns complete: "Return R-77 booked."

The server does not keep a hidden long-lived memory for this. It carries the state it needs between rounds in requestState, and the client returns it unchanged.

In simple words: the server asks questions inside its answer, and the client keeps retrying the same request until it can finish the job.

What You Will Learn

In this lesson, you will learn:

  1. What an input_required result looks like.
  2. How a client runs the MRTR loop.
  3. Why requestState is needed, and how to protect it.
  4. How retries and round limits work.
  5. What the Tasks extension adds for very long jobs.

The MCP Lab server has a book_return tool with two rounds of questions. Run it, fill in the form, and then inspect the rows in the Inspector: you will see three tools/call requests for one booking.

An MRTR flow with two rounds of questions. Each round is a new tools/call that carries the new answers and the server's requestState.

The input_required Result

What the Server Returns

Instead of finishing the tool call right away, the server pauses it and returns an input_required result:

FieldMeaning
resultType"input_required"
inputRequestsA map of named requests, such as details or confirm. Each entry is usually an elicitation/create request, and other request types may appear in older or specialized flows.
requestStateOptional text the client must send back exactly as it received it

Read an input_required result

// A real input_required result from the lab's book_return tool (trimmed).
const result = {
  resultType: "input_required",
  inputRequests: {
    details: {
      method: "elicitation/create",
      params: {
        mode: "form",
        message: "Return details for order A-1001",
        requestedSchema: { type: "object", properties: {} },
      },
    },
  },
  requestState: '{"step":1}',
};

console.log("resultType:   ", result.resultType);
for (const [key, req] of Object.entries(result.inputRequests)) {
  console.log(
    `request "${key}": ${req.method} (${req.params.mode}) - ${req.params.message}`,
  );
}
console.log("requestState: ", result.requestState, "(send it back unchanged)");

Expected output:

resultType:    input_required
request "details": elicitation/create (form) - Return details for order A-1001
requestState:  {"step":1} (send it back unchanged)

Fill in the first form. Try a bad email first: the lab's form rejects it. Then the server asks you to confirm the pickup.

In the SDK

So far, you have seen how MRTR works at the protocol level. SDKs provide helpers that make it easier to return an input-required response and read answers from later rounds.

On the server, a handler returns inputRequired({ inputRequests, requestState }). On the next round, it reads the answers from ctx.mcpReq.inputResponses and the state from ctx.mcpReq.requestState(). The helper acceptedContent(responses, key, schema) only returns a value if the user accepted it and the answer matches the schema.

The Client Loop

Retrying the Request

For each round, the client:

  1. Reads inputRequests.
  2. Collects each answer by showing a form, opening a page, or asking the user to confirm it.
  3. Sends the same logical request again as a new request with a new id, adding:

    • inputResponses: the answers under the same keys.
    • requestState: the exact value returned by the last result.

This is the key idea in MRTR: the request is not a brand-new tool call; it is the same operation retried with more information.

The MRTR loop

// The client's MRTR loop. Each round is a NEW request with a NEW id.
function fakeServer(params) {
  const step = params.requestState ? JSON.parse(params.requestState).step : 1;
  if (step === 1) {
    return {
      resultType: "input_required",
      requestState: '{"step":2}',
      inputRequests: { a: { method: "elicitation/create" } },
    };
  }
  if (step === 2) {
    return {
      resultType: "input_required",
      requestState: '{"step":3}',
      inputRequests: { b: { method: "elicitation/create" } },
    };
  }
  return { resultType: "complete", content: [{ type: "text", text: "Return booked." }] };
}

const MAX_ROUNDS = 10; // the official TypeScript SDK default
let id = 41;
let params = { name: "book_return", arguments: { order_id: "A-1001" } };
for (let round = 1; round <= MAX_ROUNDS; round++) {
  id++;
  const r = fakeServer(params);
  console.log(`round ${round}, request id ${id}: ${r.resultType}`);
  if (r.resultType === "complete") {
    console.log(r.content[0].text);
    break;
  }
  const answers = Object.fromEntries(
    Object.keys(r.inputRequests).map((k) => [k, { action: "accept", content: {} }]),
  );
  params = { ...params, inputResponses: answers, requestState: r.requestState };
}

Expected output:

round 1, request id 42: input_required
round 2, request id 43: input_required
round 3, request id 44: complete
Return booked.

Round Limits

MRTR depends on the server eventually finishing the workflow. If a bug causes it to keep asking for input, the client could enter an endless loop. To prevent this, clients enforce a maximum number of rounds. The official TypeScript SDK stops after 10 rounds by default and returns an error. Good servers usually need only one or two rounds.

requestState

The server may need information collected in earlier rounds to finish the workflow. Later requests do not automatically include those answers.

Why It Is Needed

A server is not required to keep long-lived in-memory state across requests, so any earlier information it needs must travel in requestState.

Earlier answers live in requestState

// Why requestState matters: each retry carries only the NEW answers.
const round2 = {
  inputResponses: { confirm: { action: "accept", content: { ok: true } } },
  requestState: JSON.stringify({
    step: 2,
    details: { boxes: 2, pickup_date: "2026-10-12" },
  }),
};

console.log("answers in this round:", Object.keys(round2.inputResponses).join(", "));
console.log(
  "round-1 details are not in inputResponses:",
  !("details" in round2.inputResponses),
);
const state = JSON.parse(round2.requestState);
console.log("...but the server put them in requestState:", JSON.stringify(state.details));

Expected output:

answers in this round: confirm
round-1 details are not in inputResponses: true
...but the server put them in requestState: {"boxes":2,"pickup_date":"2026-10-12"}

A good server treats requestState as opaque data it supplied earlier. It should validate it before trusting it.

Protect It

Anything sent from the server to the client could be modified before it returns. If important decisions depend on requestState, the server must protect it against tampering.

requestState goes from the server to the client and then back to the server. A malicious or buggy client could change it, for example to increase a refund amount. If the state affects money, access, or other important decisions, the server should sign or encrypt it and reject altered state. The TypeScript SDK includes createRequestStateCodec, which signs state with HMAC.

Sign requestState (toy version)

// requestState comes back from the client, so a client could change it.
// Servers protect it with a signature. This toy version shows the idea;
// real servers use HMAC (the TypeScript SDK has createRequestStateCodec).
const SECRET = "server-only-secret";
function sign(text) {
  let h = 0;
  for (const ch of SECRET + text) {
    h = (h * 31 + ch.charCodeAt(0)) >>> 0;
  }
  return h.toString(16);
}
const pack = (obj) => {
  const body = JSON.stringify(obj);
  return body + "." + sign(body);
};
function unpack(state) {
  const i = state.lastIndexOf(".");
  const body = state.slice(0, i);
  return sign(body) === state.slice(i + 1)
    ? JSON.parse(body)
    : "rejected: state was changed";
}

const minted = pack({ step: 2, refund: 20 });
console.log("minted: ", minted);
console.log("honest: ", JSON.stringify(unpack(minted)));
console.log("changed:", unpack(minted.replace('"refund":20', '"refund":2000')));

Expected output:

minted:  {"step":2,"refund":20}.a0167bd9
honest:  {"step":2,"refund":20}
changed: rejected: state was changed

Never put secrets, tokens, or personal data in plain requestState. The client can read it. Keep it small: a step number and the ids you need, signed.

Design Tips

Every additional round adds latency and requires another client-server exchange. Too many rounds can make a workflow slower and more frustrating for users.

Ask Everything You Can at Once

Each round costs a full request. Put all the questions you know you need into one round. Use a second round only when the next question depends on the previous answer, such as a final confirmation step.

Networks are unreliable. Sometimes the client cannot tell whether the server completed the final step before the connection was interrupted. In that situation, retrying may be the safest option.

Make the Final Step Safe to Repeat

Design the final action so that repeating it does no harm. A good pattern is to check whether the booking already exists before creating a duplicate. This is the same idea as idempotency.

Use the Inspector to debug MRTR flows. Each round is its own row. Click a row to see the inputResponses and requestState that the client sent.

The Tasks Extension

When Minutes Matter

MRTR is best for short interactive flows. Some jobs take minutes or hours: a test suite, a large import, or a human approval. For these jobs, the optional Tasks extension (io.modelcontextprotocol/tasks) lets a server return a task handle instead of keeping the original request open.

  • The server answers with resultType: "task", a taskId, a pollIntervalMs, and a ttlMs.
  • The client polls with tasks/get. The task status can be working, input_required, completed, failed, or cancelled.
  • If the task needs more input, the client answers with tasks/update. It can also stop a task with tasks/cancel.

Both sides must advertise support for the extension in their capabilities. You will meet extensions again in Chapter 7.

Poll a task

// The Tasks extension: a long job returns a task handle; the client polls.
const statuses = ["working", "working", "input_required", "working", "completed"];
let poll = 0;
function tasksGet(taskId) {
  const status = statuses[Math.min(poll++, statuses.length - 1)];
  return {
    taskId,
    status,
    ...(status === "completed"
      ? { result: { content: [{ type: "text", text: "Report ready" }] } }
      : {}),
  };
}

const created = {
  resultType: "task",
  taskId: "task-7f3",
  status: "working",
  pollIntervalMs: 2000,
  ttlMs: 3600000,
};
console.log(
  "tools/call ->",
  created.resultType,
  created.taskId,
  "poll every",
  created.pollIntervalMs,
  "ms",
);
for (;;) {
  const t = tasksGet(created.taskId);
  console.log("tasks/get ->", t.status);
  if (t.status === "input_required") {
    console.log("   client answers with tasks/update");
  }
  if (["completed", "failed", "cancelled"].includes(t.status)) {
    console.log("result:", t.result.content[0].text);
    break;
  }
}

Expected output:

tools/call -> task task-7f3 poll every 2000 ms
tasks/get -> working
tasks/get -> working
tasks/get -> input_required
   client answers with tasks/update
tasks/get -> working
tasks/get -> completed
result: Report ready

Common mistakes

  • Reusing the request id. Each round is a new request with a new id.
  • Changing the arguments between rounds. The retry must repeat the same method and arguments, with inputResponses and requestState added.
  • Losing state. Round 2 does not include round 1's answers. Put earlier facts in requestState.
  • Trusting unsigned state. A client can change requestState. Sign it when it matters.
  • Endless rounds. Clients give up after a limit. Finish in as few rounds as you can.

Exercises

Each exercise ends with a value on the last line. Press Check to compare it with the expected answer.

What to Remember

  • A server asks for input by returning resultType: "input_required" with named inputRequests.
  • The client answers and retries the same request as a new request id, with inputResponses and requestState copied exactly.
  • Retries carry only new answers. Earlier facts must travel in requestState, and the server should validate or sign it when it matters.
  • Clients limit rounds: the TypeScript SDK stops at 10. Ask everything you can at once.
  • For long jobs, the Tasks extension returns a taskId, and the client polls with tasks/get until the task is terminal.

Next: Elicitation, the forms and pages a server can ask the user to fill in.