this.elicit
this.elicit() asks the user a question while a tool is running. The client shows a form, and the tool continues with the answer. Use it to confirm an action before taking it, or to collect a detail the model can't know. To remember the answer, so the user isn't asked every time, see the Approval plugin.
const answer = await this.elicit(message, requestedSchema, options?)
Reference
this.elicit(message, requestedSchema, options?)
Call this.elicit() inside a tool's execute(), and turn elicitation on for the server with elicitation: { enabled: true }.
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({ name: "close_ticket", description: "Close a ticket after the user confirms", inputSchema: { id: z.string() } })
class CloseTicket extends ToolContext {
async execute({ id }: { id: string }) {
const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() }));
if (answer.status !== "accept" || !answer.content?.confirm) return { closed: false };
return { closed: true, id };
}
}
@App({ id: "desk", name: "Help desk", tools: [CloseTicket] })
class HelpDesk {}
@FrontMcp({ info: { name: "Help desk", version: "1.0.0" }, apps: [HelpDesk], elicitation: { enabled: true } })
export default class Server {}Parameters
| Parameter | Type | Description |
|---|---|---|
message | string | The question, shown above the form. Say what will happen, like "Close ticket T-1? The customer will be notified." |
requestedSchema | Zod object, or an object of Zod types | The form's fields. FrontMCP sends it as JSON Schema, with each .describe() as the field's description. Pass a z.object() to get typed content. |
options | ElicitOptions | Optional. See below. |
options:
| Option | Type | Description |
|---|---|---|
mode | "form" | "url" | "form" (the default) asks for the fields in the client. "url" asks the client to send the user to a web page, url: see Sending the user to a web page. |
ttl | number | How long to wait for an answer, in milliseconds. Default 300000 (5 minutes). Only protocol versions before 2026-07-28 wait; see how it travels. |
url | string | The page to send the user to, for URL mode. Required with mode: "url": without it, this.elicit() throws url is required when mode is "url", and the call fails with INVALID_INPUT. (New in 1.9.2.) |
elicitationId | string | An id for URL mode, sent with the request so the web page can say which request it completes. Pass your own, which your page and your tool can both use; left out, FrontMCP makes one up, elicit- and a random UUID. (Changed in 1.9.3: before, it was required, and the call failed with INVALID_INPUT without it.) |
Returns
A promise of an ElicitResult:
| Field | Type | Description |
|---|---|---|
status | "accept" | "decline" | "cancel" | accept: the user submitted the form. decline: they said no. cancel: they dismissed it without choosing. An answer with no action counts as cancel. |
content | the schema's type | What the user entered. Present when status is "accept", in form mode; a URL-mode answer has none. |
Server options
@FrontMcp({ elicitation }) takes:
| Option | Type | Description |
|---|---|---|
enabled | boolean | Default false. While it's off, every this.elicit() throws, and the tool call fails with ELICITATION_DISABLED. |
redis | RedisOptions | Where to keep pending elicitations for protocol versions before 2026-07-28. Defaults to the server's redis, else its sqlite, or memory. See Several instances and encryption. |
Turning elicitation on also changes what clients see. For clients on protocol versions before 2026-07-28 that can't show forms, FrontMCP adds the sendElicitationResult tool to tools/list, for its fallback. And every tool that declares an outputSchema advertises a oneOf of that schema and an elicitationPending result.
How an elicitation travels
Under MCP 2026-07-28 the server never sends a request to the client, and nothing waits on the server. Instead:
-
The tool runs until
this.elicit(). FrontMCP stops it and answers the original request withresultType: "input_required", the question ininputRequests, and a signedrequestState:{ "resultType": "input_required", "inputRequests": { "elicitation-1": { "method": "elicitation/create", "params": { "message": "Close ticket T-1?", "requestedSchema": { "type": "object", "properties": { "confirm": { "type": "boolean" } } } } } }, "requestState": "eyJyIjp7fS…" } -
The client shows the form. It then sends the same request again, with a new JSON-RPC id, adding the answer under the same key and the
requestStateit got:{ "name": "close_ticket", "arguments": { "id": "T-1" }, "inputResponses": { "elicitation-1": { "action": "accept", "content": { "confirm": true } } }, "requestState": "eyJyIjp7fS…" } -
FrontMCP runs the tool again from the top. This time
this.elicit()returns the answer, and the tool finishes. A secondthis.elicit()in the same run starts another round, with the keyelicitation-2.
Before 2026-07-28, FrontMCP sends an elicitation/create request to the client instead, and this.elicit() waits for the answer, up to ttl.
Several instances and encryption
Under 2026-07-28, any instance can serve any round: the question and the earlier answers travel in requestState, and nothing is stored. Every instance needs the same VAULT_SECRET or JWT_SECRET to read it (caveats).
Before 2026-07-28, the tool waits on the instance that asked, holding the call's stream open, and the client sends its answer in a POST of its own, which a load balancer can send to another instance. So FrontMCP keeps each pending question in an elicitation store, under the session's id, where the instance that receives the answer can find it:
| Store | Used when | An answer that reaches another instance |
|---|---|---|
| Redis | elicitation.redis is set, else the server's redis | Is passed on. That instance reads the question from Redis, under <keyPrefix>pending:<session id>, and publishes the answer on the channel <keyPrefix>result:<elicit id>, which the asking instance subscribed to. The tool goes on there, and the client gets 202 for its answer. |
| SQLite | The server has sqlite and no redis | Isn't. The answer is passed on inside one process: a second process that shares the file answers 202, and the tool waits out ttl and fails with ELICITATION_TIMEOUT. |
| Redis, from the environment | None of these, and REDIS_URL or REDIS_HOST is set | Isn't, in practice: sessions stay in memory, so the other instance answers 404 session not found. |
| Memory | None of these | Isn't, for the same reason. |
The startup log names the store, its keyPrefix (the redis option's, mcp: by default, or mcp:elicit:) and whether it encrypts: Created elicitation store { type: 'redis', keyPrefix: 'mcp:', supportsPubSub: true, encrypted: true }. For the answer to reach the store at all, the instance has to serve the session, which takes redis and the same MCP_SESSION_SECRET everywhere (How a session moves between instances). Without Redis, give clients on older protocol versions affinity at the load balancer, so every request of a session reaches the instance that holds it. The fallback uses the same store: its question is kept under <keyPrefix>fallback:<elicit id>, and the instance that receives sendElicitationResult runs the tool again itself.
The store needs publish and subscribe, so some places refuse it:
- Vercel KV has no publish and subscribe.
elicitation: { enabled: true }with Vercel KV as the only store fails withElicitationNotSupportedError: Vercel KV is not supported for elicitation stores; giveelicitation.redisa Redis (Vercel). - An edge isolate, like a Cloudflare Worker, refuses a store in memory. Without one in Redis, the server doesn't start: every request answers
503SERVER_START_FAILED, and the log saysElicitationNotSupportedError: Elicitation requires distributed storage when running on Edge runtime. That happens whatever protocol version the clients use, and a Worker can't reach Redis (Cloudflare Workers), so leaveelicitationoff on an edge isolate.
Encryption. When MCP_ELICITATION_SECRET, MCP_SESSION_SECRET or MCP_SERVER_SECRET is set, checked in that order, FrontMCP encrypts what it stores: the question, with its message and schema; the fallback's record, with the tool's name and arguments; and each answer it publishes to another instance. It uses AES-256-GCM, with a key derived (HKDF-SHA256) from the secret and the session's id, so reading a record takes both, and each session's records have a key of their own. A stored question keeps only sessionId, elicitId, createdAt and expiresAt readable, next to __encrypted: { alg: "A256GCM", iv, tag, data }. There's no option for it: with none of the three variables set, the store holds plain JSON. Production requires MCP_SESSION_SECRET for clients on older protocol versions, so a production server that serves them encrypts their questions.
Every instance needs the same secret. With different MCP_ELICITATION_SECRETs, the instance that received the answer logged [EncryptedElicitationStore] Failed to decrypt pending record and still answered the client 202, and the tool on the asking instance failed with ELICITATION_TIMEOUT when ttl ran out.
This was checked with FrontMCP 1.9.4 on Node: two instances sharing Valkey 8 in Docker, and a client on 2025-11-25 that opened its session and called the tool on one and sent its answer to the other: with no secret, with MCP_SESSION_SECRET on both, with different MCP_ELICITATION_SECRETs, and with the fallback. Also two instances without Redis, two with only REDIS_URL, and two processes sharing a SQLite file. The edge isolate was checked in Node with the global that marks one, as on createFetchHandler().
Caveats
this.elicit()is a protected method ofToolContext(andAgentContext: see Asking the user from an agent). Call it from inside the class.- Code before
this.elicit()runs once per round. Put side effects, like writes and emails, after the lastthis.elicit(). - Under 2026-07-28, FrontMCP checks an accepted form answer against the schema and fails the call with
INVALID_INPUTif it doesn't fit. Answers from clients on older protocol versions aren't checked, so parsecontentyourself if you serve them. An in-processconnect()client's are, though: one that doesn't fit fails the call withINVALID_INPUTandInvalid elicitation result content. See Checking the answer. - Keep the schema to a flat object of strings, numbers, booleans and enums. That's what MCP clients render as a form. FrontMCP doesn't enforce it.
- Ask for everything in one form when you can. Each question is another round trip, and runs
execute()again; see Asking more than once. - Under 2026-07-28, set
VAULT_SECRET(orJWT_SECRET) to the same value on every instance. FrontMCP signsrequestStatewithVAULT_SECRET, elseJWT_SECRET, else a random key each process makes for itself. With that random key, a round that lands on another instance counts as a changed state, and the tool starts over. In production, FrontMCP (since 1.8.7) logs a warning at startup when the key is per-process andredisortransport.persistenceis set, and the log line for a rejected state carries a hint that namesVAULT_SECRET. This was checked on a Node server. mode: "url"needsurl, and a client that declareselicitation: { url: {} }. (Changed in 1.9.2: before, FrontMCP sent no URL with the request under 2026-07-28, so the client had no page to open.)
Usage
Asking the user to confirm
Ask before doing something the user might regret, and handle every status. The server here turns elicitation on itself; Playgrounds that call this.elicit() without a server do it for you. The Call tab shows the form the user would see.
import { Tool, ToolContext, z } from "@frontmcp/sdk";
const Confirm = z.object({
confirm: z.boolean().describe("Close the ticket"),
note: z.string().optional().describe("A note for the customer"),
});
@Tool({
name: "close_ticket",
description: "Close a support ticket. Asks the user to confirm first.",
inputSchema: { id: z.string().describe("Ticket id, like T-1") },
})
export class CloseTicket extends ToolContext {
async execute({ id }: { id: string }) {
const answer = await this.elicit(`Close ticket ${id}? The customer will be notified.`, Confirm);
if (answer.status !== "accept") return { closed: false, reason: `The user chose ${answer.status}` };
if (!answer.content?.confirm) return { closed: false, reason: "The user didn't confirm" };
return { closed: true, id, note: answer.content.note ?? "" };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Answer the form with Accept, Decline or Cancel, then open the Wire tab: the first tools/call came back input_required, and your answer went out as a second tools/call.
Checking the answer
The answer comes from the client. Under 2026-07-28, FrontMCP checks an accepted answer against the schema you asked with before this.elicit() returns, fills in defaults, and drops fields the schema doesn't have. An answer that doesn't fit fails the call with INVALID_INPUT, and the text says which fields were wrong. Clients on older protocol versions aren't checked, so a tool that serves them should parse the answer too and fail with a message the model can read, as this one does.
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
const Escalation = z.object({
team: z.enum(["billing", "engineering", "security"]).describe("Who should take the ticket"),
reason: z.string().min(10).describe("Why it can't wait"),
});
@Tool({
name: "escalate_ticket",
description: "Escalate a ticket to another team. Asks the user which team, and why.",
inputSchema: { id: z.string() },
})
export class EscalateTicket extends ToolContext {
async execute({ id }: { id: string }) {
const answer = await this.elicit(`Escalate ticket ${id}. Which team, and why?`, Escalation);
if (answer.status !== "accept") return { escalated: false };
const parsed = Escalation.safeParse(answer.content);
if (!parsed.success) this.fail(new PublicMcpError("The escalation form wasn't filled in: pick a team and give a reason of 10 characters or more."));
return { escalated: true, id, ...parsed.data };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Keeping side effects after the question
FrontMCP runs the tool again from the top for each round, so everything before this.elicit() runs twice. Here the counter stands in for a database write:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
let auditEntries = 0;
@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
async execute({ id }: { id: string }) {
auditEntries++; // 🚩 runs before the question, so once per round
const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() }));
return { closed: answer.status === "accept" && answer.content?.confirm === true, auditEntries };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Move the write below the last this.elicit(), and it runs once.
What goes over the wire
This test plays a 2026-07-28 client by hand, so you can see both requests. The Wire tab shows the same exchange.
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
async execute({ id }: { id: string }) {
const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() }));
return { closed: answer.status === "accept" && answer.content?.confirm === true, id };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Asking more than once
Each this.elicit() in a run gets the next key: elicitation-1, elicitation-2, and so on. A later question can depend on an earlier answer, like choosing an agent from the team just picked.
import { Tool, ToolContext, z } from "@frontmcp/sdk";
const agents = { billing: ["Sam", "Priya"], engineering: ["Nour", "Lee"] } as const;
@Tool({ name: "reassign_ticket", description: "Move a ticket to another team and agent", inputSchema: { id: z.string() } })
export class ReassignTicket extends ToolContext {
async execute({ id }: { id: string }) {
const first = await this.elicit(`Which team should take ${id}?`, z.object({ team: z.enum(["billing", "engineering"]) }));
if (first.status !== "accept" || !first.content) return { reassigned: false };
const team = first.content.team;
const second = await this.elicit(`Which ${team} agent?`, z.object({ agent: z.enum(agents[team]) }));
if (second.status !== "accept" || !second.content) return { reassigned: false };
return { reassigned: true, id, team, agent: second.content.agent };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
A client can send every answer so far, as the first test does, or only the latest one with the requestState it was given, as the second does: the state carries the earlier answers. A requestState that was changed, or that comes back with different arguments, is ignored, and the tool asks its first question again.
Sending the user to a web page
Some answers don't belong in a form: a password, a payment, or signing in to another service. With mode: "url", the client sends the user to a page of yours instead, with url, and FrontMCP adds an elicitationId, yours or one it makes up, so the page can tell which request it completes. The client needs to declare elicitation: { url: {} }:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({ name: "connect_billing", description: "Connect the help desk to the user's billing account", inputSchema: {} })
export class ConnectBilling extends ToolContext {
async execute() {
const state = "abc123"; // in a real server, one per request, kept to check the page's callback
const answer = await this.elicit("Sign in to your billing account to connect it", z.object({}), {
mode: "url",
url: `https://billing.example/connect?state=${state}`,
elicitationId: `billing-${state}`,
});
return { connected: answer.status === "accept" };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The answer only says that the user finished, or declined: whatever they did on the page reaches your server through the page, not through the client. So the page should keep what it learned under the elicitationId or the state it was given, for the tool to read on its next run.
A URL-mode answer carries no content, as MCP specifies, so FrontMCP doesn't check it against the schema: pass z.object({}), and an accepted answer is { status: "accept" }. connect_payroll leaves elicitationId out, and FrontMCP makes one up, as the third test shows. The tool never sees that id, though, so pass your own when the page reports back under it. (Changed in 1.9.3: before, an accepted URL-mode answer was checked against the schema, so z.object({}) failed the call with INVALID_INPUT unless it was made .optional(), and a URL-mode elicitation without an elicitationId failed with INVALID_INPUT. Changed in 1.9.2: before, under 2026-07-28, FrontMCP sent no url or elicitationId with the request, so the client had no page to open.)
Troubleshooting
"Elicitation is disabled in server configuration"
The server doesn't have elicitation: { enabled: true }, so this.elicit() threw and the call failed with ELICITATION_DISABLED. In production the message is shorter, "Elicitation is disabled on this server.", and the code is the same.
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
async execute({ id }: { id: string }) {
const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() }));
return { closed: answer.status === "accept" };
}
}
@App({ id: "desk", name: "Help desk", tools: [CloseTicket] })
class HelpDesk {}
// 🚩 No `elicitation: { enabled: true }`
@FrontMcp({ info: { name: "Help desk", version: "1.0.0" }, apps: [HelpDesk] })
export class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Error -32021: "This request requires the elicitation client capability"
Under 2026-07-28, a client declares what it supports in each request's _meta, under io.modelcontextprotocol/clientCapabilities. This client didn't declare elicitation, so FrontMCP refused to ask it anything. error.data.requiredCapabilities says what was missing: { elicitation: { form: {} } }, or { elicitation: { url: {} } } for URL mode.
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
async execute({ id }: { id: string }) {
const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() }));
return { closed: answer.status === "accept" };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
A request that names no protocol version at all, in a header or its _meta, like one from a client built for 2025-03-26, is served as 2026-07-28, but gets ELICITATION_NOT_SUPPORTED as a tool error instead, as the second test shows (changed in 1.8.5: before, it got -32021 too).
Declare elicitation: { form: {} } in the client's capabilities, with url: {} too if you use URL mode. If some clients can't show forms, give the tool an input that skips the question, like confirmed: true, so they can still use it.
The model is asked to call sendElicitationResult
That's FrontMCP's fallback for clients on protocol versions before 2026-07-28 that don't support elicitation. Instead of a form, the tool's result tells the model to ask the user, and carries the question in _meta.elicitationPending (elicitId, message, schema). The model then calls sendElicitationResult with:
| Argument | Type | Description |
|---|---|---|
elicitId | string | The id from elicitationPending. |
action | "accept" | "decline" | "cancel" | What the user chose. |
content | any | The user's answer, for accept. |
FrontMCP runs the original tool again with the same arguments, as the caller who answered, so this.auth is theirs: this.elicit() returns that answer, and the model gets the tool's final result. (Changed in 1.8.4: before, a createDirect() caller's re-run had no auth info, and a tool that read this.auth failed.) Only the caller who was asked can answer: from the same session, or, without one, as the same signed-in user. An answer from anyone else fails with ELICITATION_NOT_OWNED (-32003), and the question stays open for its owner. An unknown or expired elicitId fails too:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "close_ticket",
description: "Close a support ticket",
inputSchema: { id: z.string() },
outputSchema: z.object({ closed: z.boolean(), by: z.string() }),
})
export class CloseTicket extends ToolContext {
async execute({ id }: { id: string }) {
const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() }));
return { closed: answer.status === "accept", by: this.auth.user.sub };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
FrontMCP doesn't list sendElicitationResult to 2026-07-28 clients, or to older clients that declare elicitation support, though it still answers if called, as the last test shows. A 2026-07-28 client without the capability gets -32021 rather than the fallback.
"Elicitation request timed out"
Only protocol versions before 2026-07-28 wait for the answer. The user didn't answer within ttl, so this.elicit() threw ElicitationTimeoutError and the call failed with ELICITATION_TIMEOUT. Raise ttl for long forms, and let the error end the call.
On several instances, the answer may have reached one that couldn't pass it on, though the client got 202 for it: an instance without Redis, a second process on a SQLite file, or one with another MCP_ELICITATION_SECRET, whose log says Failed to decrypt pending record. See Several instances and encryption.
The same side effect happens twice
The code ran before this.elicit(), and FrontMCP ran the tool again for the answer. Move it after the last this.elicit(). See Keeping side effects after the question.