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.
import { defineTool, getElicitedContent, mcpElicit, inputRequired } from "h3-mcp";
const askName = mcpElicit({
message: "What is your name?",
requestedSchema: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
},
});
// One map, used to ask *and* to read.
const requests = { username: askName };
const whoamiTool = defineTool({
name: "whoami",
handler: (event) => {
const name = getElicitedContent(event, requests, "username")?.name; // string | undefined
if (!name) {
return inputRequired(event, { inputRequests: requests });
}
return { content: [{ type: "text", text: `Hello, ${name}!` }] };
},
});Nothing there is asserted. The key is checked against requests, and name is a string because the schema said type: "string" and listed it in required — see Typed by the Schema That Asked.
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.
That if (!name) is the smallest thing that works, not the safest: it treats a user who declined exactly like a user who has not been asked yet, and asks again. See “No” Is an Answer.
#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.
Important
Answers do not accumulate. inputResponses carries the answers to the interim result the client is replying to — nothing guarantees that round one's answers are still there in round three. Anything you have to remember across rounds belongs in requestState, not in the responses.
requestState does not accumulate either: it is whatever the last interim result carried. Return an interim result without one and the state is gone, so the next round starts over — from a handler that reads open() and branches on it, that looks like a flow that silently restarts instead of progressing. Re-seal on every round.
#The Four Request Kinds
Each value in inputRequests is one embedded request, built by the helper named after it:
| Builder | Method | Response |
|---|---|---|
mcpElicit(params) | elicitation/create, form | ElicitResult |
mcpElicitUrl(params) | elicitation/create, URL | ElicitResult |
createMessage(…) | sampling/createMessage | CreateMessageResult |
listRoots() | roots/list | ListRootsResult |
Sampling and roots are @deprecated as of 2026-07-28 (SEP-2577) and stay in the spec for at least twelve months; elicitation is not.
#Write the Handler Write-Once
One handler runs on every round: read what already arrived, then ask for only what is still missing. getMissingInputs narrows the map to the entries this request has no answer for, and getInputResponses types each answer by the request that asked for it, so the retry branch is checked rather than cast.
import {
getInputResponses,
getMissingInputs,
createMessage,
mcpElicit,
inputRequired,
} from "h3-mcp";
const requests = {
user_name: mcpElicit({
message: "What is your name?",
requestedSchema: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
},
}),
greeting: createMessage({
messages: [{ role: "user", content: { type: "text", text: "Write a greeting" } }],
maxTokens: 50,
}),
};
const greetTool = defineTool({
name: "greet",
handler: (event) => {
const missing = getMissingInputs(event, requests);
if (Object.keys(missing).length > 0) {
return inputRequired(event, { inputRequests: missing });
}
const answers = getInputResponses(event, requests);
// answers.user_name?: ElicitResult<{ name: string }>
// answers.greeting?: CreateMessageResult
return { content: [{ type: "text", text: `${answers.greeting!.model} said hello` }] };
},
});Asking for requests wholesale instead would re-ask what the client already answered, and the client has no way to know it may skip those — the user gets prompted for their name a second time.
getElicitedContent(event, requests, key) is the shortcut for the common case: it returns the submitted fields of an accepted form elicitation, and undefined for an answer that is missing, declined, or cancelled alike. Reach for getInputResponses when a refusal has to be told apart from a first entry.
Important
Responses are client input. Both readers type them for convenience; neither validates them. Validate anything you act on, exactly as you would params.
#Typed by the Schema That Asked
mcpElicit captures the requestedSchema as a literal type, so the answer is typed by the schema that asked for it — string/number/integer/boolean/array become string/number/boolean/string[], and a name outside required is optional:
const askProfile = mcpElicit({
message: "About you",
requestedSchema: {
type: "object",
properties: {
name: { type: "string" },
age: { type: "integer" },
subscribe: { type: "boolean" },
},
required: ["name"],
},
});
const profile = getElicitedContent(event, { profile: askProfile }, "profile");
// profile?: { name: string; age?: number; subscribe?: boolean }Nothing is re-stated, so nothing can drift from the wire schema. The key is checked too: pass the same map to inputRequired and to the reader, and asking under user_name while reading back username stops compiling — a typo that otherwise has no runtime symptom at all, only an endless re-ask.
An enum stays string, deliberately: the value is unvalidated client input, and a literal union would invite an exhaustive switch over something nothing enforces.
Note
The inference describes what was asked for, not what arrived. It is a convenience over unvalidated input, exactly like the old type argument — validate anything you act on.
A schema whose literal types are gone — annotated as ElicitRequestedSchema, or assembled at runtime — still compiles and falls back to the wire-level ElicitContent (Record<string, string | number | boolean | string[]>).
Hoist a shared schema with as const. A plain const is not a fallback but an error: TypeScript widens its type: "object" to string, which no version of mcpElicit has ever accepted.
getElicitedContent(event, key) without a map is still there for an ad-hoc read, with the content type as an explicit assertion: getElicitedContent<{ name: string }>(event, "user_name"). Nothing checks the key in that form.
#"No" Is an Answer
This is the shape that loops forever:
const nameRequests = { user_name: askName };
const name = getElicitedContent(event, nameRequests, "user_name")?.name;
if (!name) {
return inputRequired(event, { inputRequests: nameRequests }); // ⚠️
}getElicitedContent collapses missing, declined and cancelled into one undefined, so a user who dismisses the dialog is asked again, dismisses it again, and is asked again. Nothing errors and nothing times out; the flow just never ends. A key typo used to fail the same silent way — ask under user_name, read back username, and a perfectly answered request looks unanswered on every round — which is why the three-argument form checks the key against the map.
The shortcut is only safe when re-asking is genuinely the right move for a refusal, which usually means the request is idempotent and cosmetic. Otherwise branch on the response itself:
const answer = getInputResponses(event, nameRequests).user_name;
if (answer === undefined) {
return inputRequired(event, { inputRequests: nameRequests }); // first entry
}
if (answer.action !== "accept") {
return { content: [{ type: "text", text: "No problem — carrying on without a name." }] };
}getMissingInputs follows the same rule: a declined or cancelled entry counts as answered and is not re-requested.
#What inputRequired Checks
It refuses to build a result the wire could not carry, so the mistake surfaces at the server instead of one round later:
The era. The legacy transport strips
resultType, so an interim result would silently degrade into a broken final one. A legacy request gets an error naming2026-07-28.Client capabilities. The spec forbids sending an
inputRequestsentry the client never declared support for. Every embedded request's capability is checked against_meta["io.modelcontextprotocol/clientCapabilities"], and-32021(MissingRequiredClientCapability) names all of them at once, down to the sub-capability:Request Needs mcpElicit(form)elicitation: { form: {} }, or the bareelicitation: {}that means form-onlymcpElicitUrlelicitation: { url: {} }createMessagesampling: {}— plussampling: { context: {} }for a deprecatedincludeContext, andsampling: { tools: {} }fortools/toolChoice(whichCreateMessageParamsdoes not model: reach them with a hand-written request object)listRootsroots: {}A capability whose value is not an object is not a declaration, and an
elicitationrequest whosemodeis neitherformnorurlis rejected outright rather than checked against form.The method. Only the three the spec allows; anything else is a
500.A non-empty spec. At least one of
inputRequestsorrequestStateMUST be present.The state length. A
requestStateoverMAX_REQUEST_STATE_LENGTH(65 536 characters, the same bound the transport applies on the way back in) is rejected on the way out.
#Asking Only What This Client Can Answer
Those checks throw, which is right for a handler that has nothing else to offer — but a handler that can degrade should ask first. canRequestInput returns the same verdict without the throw, and getSupportedInputs narrows a whole map to what this client declared:
import { getSupportedInputs, canRequestInput, inputRequired } from "h3-mcp";
// One request, one verdict.
if (!canRequestInput(event, askName)) {
return { content: [{ type: "text", text: "Hello, stranger" }] };
}
// Or a whole menu at once: ask for the subset that is answerable.
const supported = getSupportedInputs(event, {
user_name: askName,
client_roots: listRoots(),
});
if (Object.keys(supported).length > 0) {
return inputRequired(event, { inputRequests: supported });
}Both are false/empty on a legacy request too, so one check covers both reasons an interim result could not be delivered. They compose with getMissingInputs in either order.
Do not hand-roll this. clientCapabilities.elicitation !== undefined looks equivalent and is not: a client declaring elicitation: { url: {} } cannot answer a form request, and a client sending elicitation: true declared nothing at all. Both pass the naive test and then take a -32021.
The older event.context.mcp!.requireClientCapability?.("elicitation") is still there and still looser: it takes top-level names only, so it cannot express a mode, and it does not verify that the declared value is an object. A handler that pre-checks with it can still be turned down by inputRequired. Prefer canRequestInput, which is the same code path inputRequired runs.
#requestState Is Attacker-Controlled
requestState round-trips through the client, which means the value you read back is whatever the client chose to send. The transport validates only that it is a bounded string.
Important
If requestState influences authorization, resource access, or business logic, integrity-protect it — and reject anything that fails verification. Storing a user id in plain requestState and trusting it on the retry is a privilege-escalation bug.
defineRequestState is that, ready-made: an HMAC-SHA256 codec over the Web Crypto API, with an expiry and an optional context binding.
import { defineRequestState, mcpElicit, inputRequired } from "h3-mcp";
const state = defineRequestState<{ step: number }>({
key: process.env.MCP_STATE_SECRET!, // ≥ 32 bytes, shared by every instance
ttlMs: 300_000, // default 10 minutes
bind: (event) => requireUserId(event), // reject cross-user replay; never fall back to ""
});
const wizardTool = defineTool({
name: "wizard",
handler: async (event) => {
const step = (await state.open(event))?.step ?? 0;
if (step < 2) {
return inputRequired(event, {
inputRequests: { [`step${step + 1}`]: askName },
requestState: await state.seal({ step: step + 1 }, event),
});
}
return { content: [{ type: "text", text: "done" }] };
},
});open(event) returns undefined only when the request carries no state at all — the first round. Anything present but not intact (bad MAC, expired, wrong binding, malformed) is rejected with -32602 and an opaque "Invalid request state" before your handler can act on it. Which check failed is deliberately not reported: it would be an oracle for a client probing the format.
Note what the loop above does on every round that is not the last one: it re-seals. A round that returns an interim result without a requestState drops the state, and the round after it sees open() === undefined and starts from step 0 — a flow that quietly restarts rather than one that errors.
seal(payload, event?) needs the event only to evaluate bind, so an unbound codec can mint a state outside a request (a test, a fixture, a pre-seeded flow). A bound codec throws if the event is missing, rather than minting an unbound state that open() would reject a round later.
defineRequestState validates its options when you call it, not on first use: a key under 32 bytes throws RangeError, so does a non-finite or non-positive ttlMs, and a runtime without crypto.subtle throws TypeError. Since the codec is normally a module-level constant, a short development secret is an import-time crash rather than a failure on the first round trip.
The state is signed, not encrypted. The payload is tamper-evident, but anyone holding the string can base64url-decode and read it, so keep secrets out of it.
Note
The spec also asks servers to bound replay: bind the state to the authenticated principal (bind), keep the TTL short, tie the state to the request that minted it, and — where a state must be redeemed exactly once — enforce that server-side. bind and ttlMs cover the first two. bind sees only the H3Event, so it cannot name the method being retried; put the tool name in the sealed payload and check it after open(), as the conformance fixtures do. Single-use is yours to implement, because only you know what "consumed" means — the sealed string is canonical, so it is safe to key a redeemed-set on.