Asking the User Mid-Call
Some tools can't finish on their own. A tool that closes a ticket and emails the customer is about to do something that can't be taken back. A tool that assigns a ticket needs to know who should get it, and only the person using the client knows. this.elicit() pauses a call to ask that person a question, through a form their client shows them. The model doesn't answer it. The user does.
You will learn
- How to ask the user a question with
this.elicit() - How to handle each answer: accept, decline and cancel
- What goes over the wire under MCP 2026-07-28, and why
execute()runs twice - What happens with clients that can't show a form
- How to turn elicitation on in your server, and how to test a tool that asks
A tool that acts without asking
This tool closes a ticket and emails the customer that their problem is solved. A user says "I think the login problem is sorted", and the model calls it:
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { outbox, tickets } from "./store";
@Tool({
name: "close_ticket",
description: "Close a support ticket and email the customer that it's resolved.",
inputSchema: { id: z.string().describe("Ticket id, like T-1") },
annotations: { destructiveHint: true },
})
export class CloseTicket extends ToolContext {
async execute({ id }: { id: string }) {
const ticket = tickets.get(id);
if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
ticket.status = "closed";
outbox.push(`To the customer of ${id}: "${ticket.title}" is resolved.`);
return { id, closed: true };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The ticket is closed and the email is gone before anyone confirmed that the problem really is solved. destructiveHint: true lets a client warn the user before it sends the call, but it's a hint: whether anyone is asked depends on the client. If the tool itself needs a yes before it acts, the tool has to ask.
Asking with this.elicit()
this.elicit(message, schema) asks the user a question and returns their answer. The message is the question. The schema is a z.object() describing the form the client shows: here, one checkbox.
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { outbox, tickets } from "./store";
@Tool({
name: "close_ticket",
description: "Close a support ticket and email the customer that it's resolved. Asks the user to confirm first.",
inputSchema: { id: z.string().describe("Ticket id, like T-1") },
annotations: { destructiveHint: true },
})
export class CloseTicket extends ToolContext {
async execute({ id }: { id: string }) {
const ticket = tickets.get(id);
if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
const answer = await this.elicit(
`Close ${id} ("${ticket.title}") and email the customer that it's resolved?`,
z.object({ confirm: z.boolean().describe("Tick to close the ticket") }),
);
if (answer.status === "decline") return { id, closed: false, reason: "The user said no. Leave the ticket open." };
if (answer.status === "cancel") return { id, closed: false, reason: "The user dismissed the question without answering." };
if (answer.content?.confirm !== true) return { id, closed: false, reason: "The user didn't tick the box." };
ticket.status = "closed";
outbox.push(`To the customer of ${id}: "${ticket.title}" is resolved.`);
return { id, closed: true };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The Call tab shows the form the user would see. Tick the box and press Accept, then call the tool again and try Decline and Cancel. elicit() returns an object with a status, and content when there is one:
status | What the user did | content |
|---|---|---|
"accept" | Submitted the form | The form's values, like { confirm: true } |
"decline" | Said no | none |
"cancel" | Closed the form without choosing | none |
Handle all three, and say in the result what happened. The model reads the result: "The user said no" tells it to stop, where a bare closed: false might send it straight back to try again.
Keep the schema a flat object of strings, numbers, booleans and enums. Clients have to turn it into a form, and that's all the MCP spec allows in one. .describe() on a field goes into the schema as its description, which clients show next to the field.
This tool asks on every call. To ask once and let later calls through, put the tool behind approval, which records the user's yes and checks for it before each call.
Asking for a missing detail
Elicitation isn't only for confirmations. When a tool needs something only the user can decide, it can ask for it. Here, the model may pass an agent, and if it doesn't, the tool asks the user to pick one:
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
const AGENTS = ["Nour", "Sam", "Priya"] as const;
const tickets = new Map([
["T-1", { id: "T-1", title: "Cannot log in", agent: "Nour" }],
["T-2", { id: "T-2", title: "Invoice total is wrong", agent: "" }],
]);
@Tool({
name: "assign_ticket",
description: "Assign a support ticket to an agent. If you don't know who should take it, leave `agent` out and the user will be asked.",
inputSchema: {
id: z.string().describe("Ticket id, like T-1"),
agent: z.enum(AGENTS).optional().describe("Who should take the ticket"),
},
})
export class AssignTicket extends ToolContext {
async execute({ id, agent }: { id: string; agent?: (typeof AGENTS)[number] }) {
const ticket = tickets.get(id);
if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
if (!agent) {
const answer = await this.elicit(
`Who should take ${id} ("${ticket.title}")?`,
z.object({ agent: z.enum(AGENTS).describe("Support agent") }),
);
const picked = answer.content?.agent;
if (answer.status !== "accept" || !picked || !AGENTS.includes(picked)) {
return { id, assigned: false, reason: "The user didn't pick an agent." };
}
agent = picked;
}
ticket.agent = agent;
return { id, assigned: true, agent };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Pick an agent and press Accept. Then change the call to include agent and send it again: the tool doesn't ask, because it doesn't need to.
That's the split to aim for. The model fills in inputSchema from what it knows. The user answers an elicitation, for what only they can decide. Asking for something the model could have passed just interrupts the user.
What happens on the wire
In MCP 2026-07-28, the server never holds a call open while it waits for the user. Open the Wire tab in the example above, answer the question, and look at the two tools/call requests:
-
The first
tools/callrunsexecute()until it reacheselicit(). Instead of a normal result, the call returnsresultType: "input_required", with the question ininputRequestsand a signedrequestState:{ "resultType": "input_required", "inputRequests": { "elicitation-1": { "method": "elicitation/create", "params": { "message": "Who should take T-2 (\"Invoice total is wrong\")?", "requestedSchema": { "type": "object", "properties": { "agent": { "type": "string", "enum": ["Nour", "Sam", "Priya"], "description": "Support agent" } }, "required": ["agent"], "additionalProperties": false } } } }, "requestState": "eyJyIjp7fSwicCI6…" } -
The client shows the form. Nothing is waiting on the server: the first request is finished.
-
The client sends the same
tools/callagain, with the answer ininputResponsesunder the same key, and therequestStateit was given:{ "name": "assign_ticket", "arguments": { "id": "T-2" }, "inputResponses": { "elicitation-1": { "action": "accept", "content": { "agent": "Priya" } } }, "requestState": "eyJyIjp7fSwicCI6…" } -
FrontMCP runs
execute()again from the top. This timeelicit()finds an answer forelicitation-1and returns it instead of asking, and the call finishes with a normal result.
Both messages are trimmed here; the Wire tab shows them in full, with _meta and the schema's $schema. The key counts the elicit() calls in one run of execute(): the first is elicitation-1, a second would be elicitation-2. On the wire the answer is action, and FrontMCP hands it to your code as status.
Deep diveWhat's in requestState?Show detailsHide details
requestState is a token FrontMCP creates and the client passes back without reading. It holds the answers from earlier rounds, and it's signed with an HMAC, bound to the caller and to the tool name and arguments, and expires after 10 minutes. If a returned state fails any of those checks, FrontMCP ignores it and logs a warning.
The signing key is VAULT_SECRET or JWT_SECRET from the environment. Without either, each server process picks a random key, so if you run several instances behind a load balancer, give them all the same secret.
Ask first, then act
Because execute() starts over when the answer arrives, every line before elicit() runs once per round trip. This version writes to the ticket's history before it asks. Accept the question and look at history:
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
const tickets = new Map([["T-1", { id: "T-1", status: "open", history: ["Opened by the customer"] }]]);
@Tool({
name: "close_ticket",
description: "Close a support ticket. Asks the user to confirm first.",
inputSchema: { id: z.string() },
})
export class CloseTicket extends ToolContext {
async execute({ id }: { id: string }) {
const ticket = tickets.get(id);
if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
ticket.history.push("Close requested"); // 🚩 runs on both round trips
const answer = await this.elicit(`Close ${id}?`, z.object({ confirm: z.boolean() }));
if (answer.status !== "accept" || answer.content?.confirm !== true) {
return { id, closed: false, history: ticket.history };
}
ticket.status = "closed";
ticket.history.push("Closed");
return { id, closed: true, history: ticket.history };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
"Close requested" is there twice. Reading data before you ask is fine; changing anything, sending anything or charging anything is not. Do those after the answer, as the close_ticket above does.
Testing a tool that asks
In a test, nobody is there to fill in the form. mcp.onElicitation() registers the answer the test client gives when a tool asks. The handler receives the request, with its message and requestedSchema, and returns { action, content }. Open the Tests tab:
import { test, expect } from "@frontmcp/testing";
test.describe("close_ticket", () => {
test("asks about the right ticket, and closes it on yes", async ({ mcp }) => {
let question = "";
mcp.onElicitation((request) => {
question = request.message;
return { action: "accept", content: { confirm: true } };
});
const result = await mcp.tools.call("close_ticket", { id: "T-1" });
expect(question).toContain("T-1");
expect(result.json()).toEqual({ id: "T-1", closed: true });
});
test("leaves the ticket open when the user declines", async ({ mcp }) => {
mcp.onElicitation(() => ({ action: "decline" }));
const result = await mcp.tools.call("close_ticket", { id: "T-2" });
expect(result.json()).toMatchObject({ closed: false });
});
test("an answer that doesn't match the schema is rejected", async ({ mcp }) => {
mcp.onElicitation(() => ({ action: "accept", content: { confirm: "yes" } }));
const result = await mcp.tools.call("close_ticket", { id: "T-3" });
expect(result).toBeError("INVALID_INPUT");
expect(result.text()).toContain("does not match the requested schema");
});
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The last test sends confirm: "yes", a string where the schema says boolean. FrontMCP rejects the answer before elicit() returns, so the call fails with INVALID_INPUT and execute() never gets past the question. Testing Your Server covers the rest of @frontmcp/testing.
Clients that can't ask
Not every client can show a form. Under 2026-07-28, every request says what its client supports, in _meta["io.modelcontextprotocol/clientCapabilities"]. The Playground sends { elicitation: { form: {} } }, so its calls can be asked questions. A client that leaves elicitation out gets a JSON-RPC error instead: -32021, with the capability it's missing in data.requiredCapabilities. The first test below sends a 2026-07-28 request the way such a client would, with an empty capabilities object:
import { test, expect } from "@frontmcp/testing";
test("a client without the elicitation capability gets error -32021", async ({ mcp }) => {
const response = await mcp.raw.request({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "close_ticket",
arguments: { id: "T-1" },
_meta: { "io.modelcontextprotocol/clientCapabilities": {} },
},
});
expect(response).toHaveErrorCode(-32021);
expect(response.error?.data).toEqual({ requiredCapabilities: { elicitation: { form: {} } } });
});
test("2026-07-28 clients don't see the fallback tool", async ({ mcp }) => {
expect(await mcp.tools.list()).not.toContainTool("sendElicitationResult");
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The error happens at elicit(), so the tool never gets an answer and the client gets no result. If a tool can do something useful without asking, like a read-only preview, make asking optional in its input rather than letting such clients fail.
The second test is about a tool you may see in other clients: sendElicitationResult, which you didn't write. FrontMCP adds it when elicitation is enabled, as a fallback for clients on older protocol versions that keep a session open but can't show forms. For those clients, FrontMCP tells the model what to ask instead, in the tool's result or in a log message while the call waits. The model asks the user in the chat and calls sendElicitationResult with the answer, and FrontMCP runs the original tool again with it. FrontMCP lists the tool only for those clients, so a 2026-07-28 client like the Playground never sees it; one without the capability gets -32021, as above.
Turning elicitation on
The Playground turns elicitation on for you when an example calls this.elicit(). A real server has to ask for it. This example exports its own @FrontMcp server, so the Playground uses its configuration as written:
import { App, FrontMcp } from "@frontmcp/sdk";
import { CloseTicket } from "./close-ticket.tool";
@App({ id: "help-desk", name: "Help desk", tools: [CloseTicket] })
class HelpDeskApp {}
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
})
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The call fails with ELICITATION_DISABLED: "Elicitation is disabled in server configuration", followed by how to enable it. Elicitation is off unless you enable it in @FrontMcp:
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
elicitation: { enabled: true },
})
export default class Server {}Recap
this.elicit(message, z.object({...}))asks the user a question through their client. The model doesn't answer it.- Handle
accept,declineandcancel, and say in the result what happened. Under 2026-07-28, FrontMCP rejects an accepted answer that doesn't match the schema; still check the fields ofcontentyou rely on, for older clients. - Use
inputSchemafor what the model knows and elicitation for what only the user can decide. - Under 2026-07-28 the call returns
input_required, and the client sends the same request again withinputResponses.execute()runs again from the top, so don't change anything before you ask. - A client that doesn't declare the
elicitationcapability gets error-32021. - Enable elicitation with
@FrontMcp({ elicitation: { enabled: true } }), and answer questions in tests withmcp.onElicitation().
Try some challenges
Each challenge runs hidden checks against your code. Edit the code, then press Check.
Challenge 1 of 3
Confirm before deleting
delete_ticket deletes a ticket as soon as it's called. Make it ask the user first, with a confirm checkbox and a message that names the ticket. Only delete when the user accepts and ticks the box.
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
const tickets = new Map([
["T-1", { id: "T-1", title: "Cannot log in" }],
["T-2", { id: "T-2", title: "Invoice total is wrong" }],
["T-3", { id: "T-3", title: "Login link expired" }],
["T-4", { id: "T-4", title: "Password reset email missing" }],
]);
@Tool({
name: "delete_ticket",
description: "Permanently delete a support ticket.",
inputSchema: { id: z.string().describe("Ticket id, like T-1") },
annotations: { destructiveHint: true },
})
export class DeleteTicket extends ToolContext {
async execute({ id }: { id: string }) {
const ticket = tickets.get(id);
if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
tickets.delete(id);
return { id, deleted: true };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.