Elicitation
Let a chat-path tool pause and ask the user for structured input before it completes, with server-verified state across the round trip.
MCP 2026-07-28 lets a tool answer "I need more input" instead of a result. The host renders the question to the user, then calls the same tool again with the answers attached. The protocol calls this a multi-round trip (MRTR). Use it for confirmations, missing parameters, or choices the model should not guess.
Elicitation is for chat-path tools only: tools the model calls in conversation. Hosts do not surface multi-round trips to widgets, so a tool that a view calls through
injectCallToolmust complete in one round. The testing harness enforces this:provideMockMcpUirejects cannedinput_requiredresponses.
The shape of a round trip
- Round one: the handler returns
inputRequired({ inputRequests, requestState })instead of a result.inputRequestsnames each question;requestStatecarries the handler's mid-flight context as an opaque token. - The host collects the user's answers.
- Round two: the host calls the tool again with the same arguments plus
inputResponsesand the echoedrequestState. The handler reads the answers and completes.
The SDK enforces a bound on rounds per logical call (default 8, the inputRequired server
option).
A worked example
import { acceptedContent, inputRequired } from "ng-mcp-ui/server";
import { z } from "zod";
const confirmSchema = z.object({ ok: z.boolean() });
server.registerTool(
{
name: "delete_report",
description: "Delete a report after the user confirms.",
inputSchema: z.object({ target: z.string() }),
},
async ({ target }, ctx) => {
const confirmed = acceptedContent(
ctx.mcpReq.inputResponses,
"confirm",
confirmSchema,
);
if (confirmed === undefined) {
// Round one: ask, and seal the context we need on round two.
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: `Delete ${target}? This cannot be undone.`,
requestedSchema: confirmSchema,
}),
},
requestState: await ctx.state?.sealRequestState({ target }),
});
}
if (!confirmed.ok) {
return { content: "Cancelled. Nothing was deleted." };
}
// Round two: the echo was verified and decoded before the handler ran.
const sealed = ctx.state?.requestState<{ target: string }>();
await deleteReport(sealed?.target ?? target);
return { content: `Deleted ${sealed?.target ?? target}.` };
},
);
The pieces:
acceptedContent(responses, key, schema)reads one accepted answer, validated against the schema. It returnsundefinedwhen there is no answer yet, which doubles as the round-one test. A declined or cancelled request also comes back asundefined, so a round-two handler that getsundefinedfor a request it asked should treat it as "the user said no".inputRequired.elicit({...})builds a form question.inputRequired.elicitUrl,inputRequired.createMessage, andinputRequired.listRootsbuild the other request kinds (browser hand-off, model sampling, roots listing).requestStateis minted withctx.state.sealRequestState(...), which uses the same codec as sealed state. Configure the server'sstateoption to use it.
Verified requestState
With the state option configured, ng-mcp-ui wires the codec into the SDK's requestState
verification hook. On round two:
- A valid echo is decoded before your handler runs. Read it with
ctx.state.requestState<T>(). - A tampered or expired echo never reaches your handler. The SDK answers the frozen protocol
error
-32602with the messageInvalid or expired requestState, and by design it does not say which check failed.
You never verify the echo yourself, and the handler cannot forget to.
Two bindings are sealed into every token, so a valid signature is not enough on its own:
| Binding | Effect |
|---|---|
| Purpose | A widget token from ctx.state.seal() cannot be echoed as requestState, and an MRTR token cannot be opened with ctx.state.open(). |
| Operation | An echo minted by one tool is refused by another, so a confirmation cannot be moved to a different tool. |
Add bind to the server's state option to also tie tokens to the principal, and keep
ttlSeconds short. Read ctx.state.requestState<T>() rather than the SDK's
ctx.mcpReq.requestState<T>(): with a state option configured, the SDK accessor returns the
raw signed envelope, and ctx.state.requestState() unwraps it and checks the bindings.
Host capability requirement
A server may only embed a form elicitation when the request envelope declares the client capability for it:
{ "elicitation": { "form": {} } }
When the capability is missing, the SDK rejects the call with error -32021 and lists the
required capabilities in the error data. Hosts that support elicitation declare it on every
request. In tests, put the capability into the request _meta envelope; the library's own test
suite shows the shape.
When not to use it
- From a view: never. Keep view-callable tools single-round (see the note at the top).
- For state the widget should carry across calls: use sealed state
in
_metainstead. - For free-form follow-up: if the model can simply ask in conversation, let it. Elicitation is for structured answers the tool needs to proceed.