Some tools cannot complete without asking the user a question. “Which account should I debit?” “What is the date range for this report?” “Are you sure you want to delete all 200 records?” These are not questions an LLM should guess at. They require explicit, structured input from the human in the loop. Elicitation is MCP’s mechanism for this – it lets a server, while handling a request, ask the user for information through the client’s UI, wait for the answer, and then continue. It is a synchronous human-in-the-loop pattern baked into the protocol.

How Elicitation Works
When a server needs user input, it sends an elicitation/create request to the client. The request includes a message (explaining what information is needed) and a JSON schema describing the expected response format. The client presents this to the user in whatever UI is appropriate – a dialog box, a prompt, a form – and returns the user’s structured response. The server receives the answer and continues processing.
Elicitation is a client capability – the server can only use it if the client declared elicitation support during initialisation. If the client does not support elicitation, the server must handle the lack of user input gracefully (skip the step, use defaults, or return an error with a clear explanation).
// Client: declare elicitation support
const client = new Client(
{ name: 'my-host', version: '1.0.0' },
{
capabilities: {
elicitation: {}, // Required for server-side elicitation
},
}
);
// Client: implement the elicitation handler
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
client.setRequestHandler(ElicitRequestSchema, async (request) => {
const { message, requestedSchema } = request.params;
// Show UI to user - implementation is host-specific
const userResponse = await showElicitationDialog(message, requestedSchema);
if (userResponse === null) {
// User dismissed/cancelled
return { action: 'cancel' };
}
return {
action: 'accept',
content: userResponse, // Must match requestedSchema
};
});

Server-Side Elicitation Usage
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
const server = new McpServer({ name: 'payment-server', version: '1.0.0' });
server.tool(
'process_payment',
'Processes a payment transaction with user confirmation',
{
amount: z.number().positive().describe('Amount in USD'),
recipient: z.string().describe('Recipient name or email'),
},
async ({ amount, recipient }, { server: serverInstance }) => {
// Elicit confirmation before processing
const confirmation = await serverInstance.elicitInput({
message: `You are about to send $${amount.toFixed(2)} to ${recipient}. Please confirm the payment account:`,
requestedSchema: {
type: 'object',
properties: {
account_id: {
type: 'string',
description: 'Your account ID to debit (format: ACC-XXXXXXXX)',
},
confirmed: {
type: 'boolean',
description: 'Confirm you want to proceed with this payment',
},
},
required: ['account_id', 'confirmed'],
},
});
if (confirmation.action === 'cancel') {
return { content: [{ type: 'text', text: 'Payment cancelled by user.' }] };
}
if (!confirmation.content.confirmed) {
return { content: [{ type: 'text', text: 'Payment declined - user did not confirm.' }] };
}
const result = await processPayment({
amount,
recipient,
accountId: confirmation.content.account_id,
});
return { content: [{ type: 'text', text: `Payment ${result.transactionId} processed successfully.` }] };
}
);
“Elicitation allows servers to request additional information from users during tool execution. This enables interactive workflows where user input is needed to complete tasks, while maintaining a clear separation between the AI model and the human oversight layer.” – MCP Specification, Elicitation
Elicitation Schema Constraints
The schema for an elicitation request is deliberately restricted compared to full JSON Schema. This is intentional – the schema must be renderable by any client UI, which means it cannot be arbitrarily complex. The spec defines a “flat” schema: a single object with primitive properties (string, number, boolean, or enum). No nested objects, no arrays, no $ref references.
// VALID elicitation schema - flat, primitive properties only
{
type: 'object',
properties: {
name: { type: 'string', description: 'Your full name' },
age: { type: 'number', description: 'Your age in years' },
agree_to_terms: { type: 'boolean', description: 'Do you agree to the terms?' },
plan: { type: 'string', enum: ['basic', 'pro', 'enterprise'], description: 'Choose a plan' },
},
required: ['name', 'agree_to_terms'],
}
// INVALID: Nested objects not allowed in elicitation schemas
{
type: 'object',
properties: {
address: {
type: 'object', // This will fail - nested objects not permitted
properties: { street: { type: 'string' } },
},
},
}
Failure Modes with Elicitation
Case 1: Not Handling cancel Action
Users can always cancel an elicitation. If your tool handler does not check for the cancel action, it will try to proceed with undefined data and crash or produce garbage output.
// BAD: No cancel check
const response = await serverInstance.elicitInput({ ... });
await processData(response.content.value); // Will throw if action was 'cancel'
// GOOD: Always check the action
const response = await serverInstance.elicitInput({ ... });
if (response.action !== 'accept') {
return { content: [{ type: 'text', text: 'Action cancelled.' }] };
}
await processData(response.content.value);
Case 2: Using Elicitation When a Tool Parameter Would Suffice
Elicitation is for input the server cannot know at design time – confirmation of a specific action, a password, runtime context. If the information can be a tool argument, make it a tool argument. Elicitation adds a round-trip to the user and breaks the automated flow.
// WRONG: Using elicitation for something that should be an arg
server.tool('delete_record', '...', { id: z.string() }, async ({ id }, ctx) => {
const confirm = await ctx.server.elicitInput({ message: 'Confirm deletion?', ... });
// Should this really need an interactive prompt? Or is --confirm a better pattern?
});
// BETTER: Use tool annotations + let the host handle confirmation
server.tool(
'delete_record',
'Permanently deletes a record',
{ id: z.string(), confirm: z.boolean().describe('Set true to confirm permanent deletion') },
{ annotations: { destructive: true } },
async ({ id, confirm }) => {
if (!confirm) return { isError: true, content: [{ type: 'text', text: 'Set confirm=true to proceed.' }] };
await db.delete(id);
return { content: [{ type: 'text', text: `Deleted ${id}` }] };
}
);
What to Check Right Now
- Map your interactive flows – identify any workflow in your application that requires user input mid-execution. These are elicitation candidates.
- Keep schemas flat – validate your elicitation schemas against the spec constraints: flat object, primitive values only, no nested objects or arrays.
- Always handle cancel and decline – every elicitation can result in
cancel(user dismissed) ordecline(user responded negatively). Handle all three outcomes. - Check client support first – before calling elicitInput, check
client.getClientCapabilities()?.elicitation. If the client does not support it, fall back to tool-argument-based confirmation.
nJoy 😉
