You shipped an MCP server with elicitation. Your CX agent pauses mid-execution to confirm a cancellation, collect a missing account number, or require step-up authorization before a refund. You tested it, it works, you moved on.
The July 28 final spec just deprecated how it works.
Elicitation and sampling, as originally designed, required a live SSE stream so the server could reach back to the client while a tool was running. When MCP went stateless, that channel closed. A stateless server processes a request and returns a response. It can't hold a connection open and send a message mid-execution, because there's no held connection to send it on.
So the spec needed a different pattern for human-in-the-loop. That pattern is Multi Round-Trip Requests, specified in SEP-2322. It doesn't patch the old model. It inverts it. Instead of the server reaching back through an open channel, the server returns early with a structured payload saying "I need input." The client collects that input. The client re-submits.
This article explains exactly what changed, walks through the new pattern with code, and shows how to migrate existing elicitation tools.
Why stateless broke the old elicitation model
The old elicitation and sampling patterns required an open SSE connection so the server could reach back to the client while a tool was running. Stateless MCP drops that connection between requests. Without a live channel, a server can't call extra.session.client.request. The whole pattern fails at the transport layer.
Before SEP-2322, MCP had two ways for a server to talk back to the client mid-execution: sampling and elicitation. Sampling let the server ask the client's LLM to run a completion. Elicitation let the server ask the user for structured input.
Both relied on the same underlying mechanism: a persistent SSE stream that stayed open for the duration of a session. The server called extra.session.client.request() to send a message through that open channel, and the response came back asynchronously before the handler continued.
// OLD PATTERN -- deprecated in July 28 spec
server.tool("cancel-subscription", {
accountId: z.string(),
}, async ({ accountId }, extra) => {
const account = await getAccount(accountId);
// Called back through open SSE stream (requires persistent session)
const response = await extra.session.client.request(
{
method: "elicitation/create",
params: {
message: `Cancel ${account.planName} for ${account.name}?`,
requestedSchema: {
type: "object",
properties: { confirmed: { type: "boolean" } },
required: ["confirmed"]
}
}
},
ElicitationResultSchema
);
if (!response.action || !response.content?.confirmed) {
return { content: [{ type: "text", text: "Cancellation aborted." }] };
}
await cancelSubscription(accountId);
return { content: [{ type: "text", text: "Subscription cancelled." }] };
});In a stateful deployment behind a sticky load balancer, this worked fine. In a serverless function that spins up per request, or behind a round-robin load balancer, extra.session is either absent or unreliable. The stateless migration guide covers the broader transport changes. Elicitation was the last major pattern that didn't fit the model -- SEP-2322 fixes it.
SEP-2260, also in the July 28 spec, adds a constraint that makes the fix robust: server-initiated requests are now only permitted while the server is actively processing a client request. The server can't fire off calls to the client at random. This keeps the protocol's request-response contract clean.
How Multi Round-Trip Requests work
SEP-2322 replaces server-initiated calls with a pattern where the server returns a structured intermediate result. When your tool handler reaches a point where it needs user input, it returns an InputRequiredResult instead of a final result.
The InputRequiredResult has three fields:
resultType: Set to "inputRequired". The client checks this to know a round-trip is happening.
inputRequests: A named map. Each key is the name of an input you're collecting. Each value has a type (currently "elicitation"), a message shown to the user, and a JSON Schema for the expected answer. The schema drives validation on the client side before re-issue.
requestState: An opaque base64-encoded blob. You encode anything your handler needs to resume: parameters it already validated, a step counter, the original data to prevent relying on client-supplied re-issue values. The client echoes it unchanged.
{
"resultType": "inputRequired",
"inputRequests": {
"confirmed": {
"type": "elicitation",
"message": "Cancel the Pro plan for Meridian Corp? This action cannot be undone.",
"schema": { "type": "boolean" }
}
},
"requestState": "eyJhY2NvdW50SWQiOiJhY2NfODgyMSIsInBsYW5OYW1lIjoiUHJvIiwic3RlcCI6ImNvbmZpcm0ifQ=="
}The client renders inputRequests. For a boolean, that's a confirm dialog. For a string, a text field. For an enum, a picker. It validates the response against the schema, builds an inputResponses map, and re-issues the original tool call with inputResponses and the echoed requestState attached.
Your handler receives the re-issue, deserializes requestState, reads inputResponses, and completes execution.
Any server instance can handle the re-issue. The state is in the payload.
Writing a tool with the new pattern
Here's the same cancellation tool rewritten for SEP-2322:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const server = new McpServer({ name: "billing-server", version: "2.0.0" });
server.tool(
"cancel-subscription",
{
accountId: z.string(),
// New fields added by round-trip
inputResponses: z.record(z.unknown()).optional(),
requestState: z.string().optional(),
},
async ({ accountId, inputResponses, requestState }) => {
// First call: no inputResponses. Return InputRequiredResult.
if (!inputResponses) {
const account = await getAccount(accountId);
const state = Buffer.from(
JSON.stringify({ accountId, planName: account.planName, step: "confirm" })
).toString("base64");
return {
resultType: "inputRequired" as const,
inputRequests: {
confirmed: {
type: "elicitation",
message: `Cancel ${account.planName} for ${account.name}? This cannot be undone.`,
schema: { type: "boolean" }
}
},
requestState: state,
};
}
// Re-issue: inputResponses and requestState present. Resume.
const ctx = JSON.parse(Buffer.from(requestState!, "base64").toString("utf-8"));
if (!inputResponses.confirmed) {
return { content: [{ type: "text", text: "Cancellation aborted. Subscription is still active." }] };
}
await cancelSubscription(ctx.accountId);
return { content: [{ type: "text", text: `${ctx.planName} subscription cancelled successfully.` }] };
}
);A few details worth noting.
The handler branches on inputResponses presence, not a separate endpoint. Both calls go to the same function. You check once at the top and either return the round-trip payload or execute with the collected input.
You should store the accountId in requestState rather than relying on the re-issued payload. The client echoes the same arguments, but you don't control the client. Validate against what you put in requestState, not what came in from the re-issue.
Keep requestState small. It travels through the client on every round-trip. A few hundred bytes is fine. A full database record is not.
Multi-step round-trips
Extend the pattern to collect multiple inputs by including a step counter in requestState. The server returns a new InputRequiredResult at each step until it has everything it needs. The client re-issues after each one, echoing the requestState that advances the counter. All state lives in the payload -- no session needed, no server affinity required.
Some flows need more than one question. The pattern extends by including a step counter in requestState and returning a new InputRequiredResult from each step until you have everything you need.
For a shipping claim tool that collects email and damage description before filing:
server.tool(
"file-shipping-claim",
{
orderId: z.string(),
inputResponses: z.record(z.unknown()).optional(),
requestState: z.string().optional(),
},
async ({ orderId, inputResponses, requestState }) => {
// Deserialize context or start fresh
const ctx = requestState
? JSON.parse(Buffer.from(requestState, "base64").toString("utf-8"))
: { step: "email", orderId };
const encode = (data: object) =>
Buffer.from(JSON.stringify(data)).toString("base64");
// Step 1: collect contact email
if (ctx.step === "email" && !inputResponses) {
return {
resultType: "inputRequired" as const,
inputRequests: {
email: {
type: "elicitation",
message: "What email should receive the claim confirmation?",
schema: { type: "string", format: "email" }
}
},
requestState: encode(ctx),
};
}
// Step 1 response received -- advance to step 2
if (ctx.step === "email" && inputResponses) {
const newCtx = { ...ctx, step: "damage", email: inputResponses.email };
return {
resultType: "inputRequired" as const,
inputRequests: {
description: {
type: "elicitation",
message: "Describe the damage. Include the condition of the packaging.",
schema: { type: "string", minLength: 20, maxLength: 500 }
}
},
requestState: encode(newCtx),
};
}
// Step 2 response received -- file the claim
const finalCtx = JSON.parse(Buffer.from(requestState!, "base64").toString("utf-8"));
const claimId = await fileShippingClaim({
orderId: finalCtx.orderId,
email: finalCtx.email,
description: inputResponses!.description as string,
});
return {
content: [{
type: "text",
text: `Claim #${claimId} filed. Confirmation sent to ${finalCtx.email}.`
}]
};
}
);The step counter in ctx.step drives the flow. The client just sees a sequence of InputRequiredResult responses, each with a different inputRequests shape, and re-issues each time. From the client's perspective, every re-issue is just a tool call -- it doesn't know or care how many steps the server has internally.
What the client needs to handle
Custom MCP clients need to detect the inputRequired resultType and render inputRequests entries as appropriate UI elements -- confirm dialogs for booleans, text inputs for strings, pickers for enums. After collecting and schema-validating the user's responses, the client builds an inputResponses map and re-issues the original call. Clients that skip this handler will receive InputRequiredResult payloads they can't process.
If you're building a custom client, you need to handle the inputRequired resultType. The steps are:
- Call a tool. Receive the response.
- Check
resultType. If it's"inputRequired", don't surface it as a final answer. - Iterate
inputRequests. For each entry, render a UI element appropriate to its JSON Schema.booleanbecomes a confirm/cancel.stringbecomes a text input.stringwithenumbecomes a picker. - Validate user input against the schema before accepting it.
- Build the
inputResponsesmap with matching keys. - Re-issue the original tool call with the original arguments,
inputResponses, and the echoedrequestState.
Voice interfaces need adaptation. A JSON Schema confirmation dialog doesn't translate naturally to speech. For voice CX agents, map the inputRequests schema to a structured voice prompt ("To confirm the cancellation, say yes") and validate the transcribed speech against the schema before re-issuing. This is testable with Chanl's scenario runner, where you can define simulated voice inputs at each round-trip step and verify the agent handles them correctly across different user response patterns.
If you're not sure whether a connected client supports inputRequired, check the client's capability declaration during the MCP handshake. Clients that support Multi Round-Trip Requests declare an inputRequired capability. Graceful degradation means either falling back to a simpler single-call pattern or surfacing an error through your agent instead of silently returning an InputRequiredResult the client can't process.
Testing round-trip tools
Every round-trip tool needs at minimum three test cases: one verifying the first call returns a valid InputRequiredResult with the right inputRequests keys and a correctly encoded requestState, one verifying the re-issue completes when the user confirms, and one verifying the re-issue aborts correctly when the user declines. Single-call tests that only hit the happy path miss the most common failure mode: bad requestState deserialization on the re-issue.
Tests need to exercise both legs of the round-trip. A single-call test that only verifies the happy path misses the branch where user input changes the outcome.
import { describe, it, expect, vi } from "vitest";
// Pull the raw handler for unit testing
import { cancelSubscriptionHandler } from "./cancel-subscription.js";
describe("cancel-subscription round-trip", () => {
it("returns InputRequiredResult on first call", async () => {
const result = await cancelSubscriptionHandler({
accountId: "acc_8821",
inputResponses: undefined,
requestState: undefined,
});
expect(result.resultType).toBe("inputRequired");
expect(result.inputRequests).toHaveProperty("confirmed");
expect(result.inputRequests.confirmed.schema.type).toBe("boolean");
expect(typeof result.requestState).toBe("string");
// Verify requestState encodes accountId
const ctx = JSON.parse(Buffer.from(result.requestState, "base64").toString("utf-8"));
expect(ctx.accountId).toBe("acc_8821");
});
it("cancels when user confirms", async () => {
const initial = await cancelSubscriptionHandler({
accountId: "acc_8821",
inputResponses: undefined,
requestState: undefined,
});
const result = await cancelSubscriptionHandler({
accountId: "acc_8821",
inputResponses: { confirmed: true },
requestState: initial.requestState,
});
expect(result.content[0].text).toContain("cancelled successfully");
});
it("aborts when user declines", async () => {
const initial = await cancelSubscriptionHandler({
accountId: "acc_8821",
inputResponses: undefined,
requestState: undefined,
});
const result = await cancelSubscriptionHandler({
accountId: "acc_8821",
inputResponses: { confirmed: false },
requestState: initial.requestState,
});
expect(result.content[0].text).toContain("still active");
});
});For integration-level testing, you want to run through a complete agent scenario: the agent calls the tool, receives InputRequiredResult, a simulated user responds, the agent re-issues, and you verify the final CX outcome. Chanl's scenario testing supports this by letting you define the full conversation path as a named test case, including the round-trip steps. You can run these scenarios before any deployment and use them as regression tests when you modify the tool.
What this means for CX tool design
Multi Round-Trip Requests change the agent's visibility into tool execution. The agent now sees when a tool needs input, knows what input is needed, and can describe the pause to the user. This makes human-in-the-loop flows a first-class part of the agent's conversation, not an invisible side effect happening inside the tool handler.
This isn't just a transport change. It changes how you think about tool interfaces.
Old elicitation happened inside the tool handler, invisibly to the agent. The agent called the tool and waited. What happened during the wait -- the user being asked something, the elicitation completing -- was opaque to the agent's reasoning loop.
Multi Round-Trip Requests surface the pause. The agent sees an InputRequiredResult and knows the tool needs input. Depending on how you write your agent's tool-use logic, it can explain to the user why it's pausing, handle the input collection itself, or pass control back to the caller. The interaction is explicit.
For CX agents specifically, this matters for transparency. If your agent is processing a refund and needs a manager's authorization, the old pattern would pause the agent mid-call while the server reached back to the client. The new pattern returns a structured payload the agent can describe to the user: "I need confirmation from your account manager before I can proceed." The agent's reasoning about the pause is part of the conversation, not a hidden side effect.
It also changes how you design multi-step tools in your MCP tool registry. A tool that needs structured input partway through should now encode that in its return type, not in documentation comments. If the tool can return InputRequiredResult, it should declare that in its description so the agent can anticipate it.
The July 28 spec is final. If you have existing elicitation tools, the old pattern still works -- it's deprecated, not removed, and the spec lifecycle policy gives you at least 12 months before removal. But new tools should use Multi Round-Trip Requests from the start. The migration is straightforward: replace the extra.session.client.request call with a return, add the requestState branch to the handler, update your tests. Most tools take under an hour.
For a deeper look at the broader stateless migration -- sessions, transport changes, Tasks -- see MCP Servers Don't Need Sessions Anymore and MCP's 2026 spec changes what your tools can do.
Your CX agent that pauses before a cancellation, collects a missing account number, or requires authorization before a refund -- that agent still works. The pattern just lives in a return value now instead of a live stream. Explicit, stateless, and fully testable end-to-end.
Test your round-trip tools before they reach production
Chanl's scenario runner lets you define the full multi-round-trip path for every MCP tool, simulate different user inputs at each step, and verify your CX agent reaches the right outcome. Build the test before you build the tool.
Explore ScenariosCo-founder
Building the platform for AI agents at Chanl — tools, testing, and observability for customer experience.
El briefing de Signal
Un email por semana. Cómo los equipos líderes de CS, ingresos e IA están convirtiendo conversaciones en decisiones. Benchmarks, playbooks y lo que funciona en producción.

