Multi Round-Trip Requests
Ask the client for input by returning, not by calling.
Modern servers no longer send sampling/createMessage, elicitation/create, or roots/list as requests of their own. Instead a tools/call, resources/read, or prompts/get handler returns an interim result describing what it needs, and the client retries the original request with the answers attached.
const whoamiTool = defineMcpTool({
name: "whoami",
handler: (event) => {
const answer = event.context.mcp?.inputResponses?.username as any;
if (!answer?.content?.name) {
return {
resultType: "input_required",
inputRequests: {
username: {
method: "elicitation/create",
params: { mode: "form", message: "What is your name?" },
},
},
requestState: signedState(),
};
}
return { content: [{ type: "text", text: `Hello, ${answer.content.name}!` }] };
},
});The shape of the handler is the important part: it is re-entrant. The same code runs on the first call and on the retry, branching on whether the answers are present. There is no suspended continuation on the server — that is what makes the modern era stateless.
#Anatomy
| Field | Direction | Purpose |
|---|---|---|
inputRequests | out | Named requests for the client to satisfy (elicitation/create, …) |
requestState | out | Opaque string the client echoes back, so you can resume |
inputResponses | in | The answers, keyed by the same names, on event.context.mcp |
requestState | in | Your string, verbatim, on event.context.mcp |
Several inputs can be requested at once — key them and read them back by key. Interim results never carry caching hints.
#requestState Is Attacker-Controlled
requestState round-trips through the client, which means the value you read back is whatever the client chose to send. h3-mcp validates only that it is a bounded string.
Important
If requestState influences authorization or business logic, integrity-protect it. Sign it (HMAC) or encrypt it (AEAD), include the tool name and an expiry, and reject anything that fails verification. Storing a user id in plain requestState and trusting it on the retry is a privilege-escalation bug.
import { createHmac, timingSafeEqual } from "node:crypto";
function sign(payload: object): string {
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
const mac = createHmac("sha256", process.env.STATE_SECRET!).update(body).digest("base64url");
return `${body}.${mac}`;
}
function verify(state: string | undefined): any {
if (!state) return undefined;
const [body, mac] = state.split(".");
if (!body || !mac) return undefined;
const expected = createHmac("sha256", process.env.STATE_SECRET!).update(body).digest("base64url");
if (mac.length !== expected.length) return undefined;
if (!timingSafeEqual(Buffer.from(mac), Buffer.from(expected))) return undefined;
const payload = JSON.parse(Buffer.from(body, "base64url").toString());
return payload.exp > Date.now() ? payload : undefined;
}#Requiring a Capability
Before asking for input, confirm the client can provide it. requireClientCapability throws -32021 (MissingRequiredClientCapability) listing what was missing:
event.context.mcp!.requireClientCapability?.("elicitation");Capabilities come from _meta["io.modelcontextprotocol/clientCapabilities"] on the request in flight.
Note
There is no helper yet for constructing or signing requestState — resultType: "input_required" passes through and inputResponses / requestState are surfaced, but the ergonomic wrapper is still on the roadmap. Until then, use a signing helper like the one above.