Elicitation
A tool handler asks the end user a question mid-call with ctx.mcpReq.elicitInput — the connected client puts the question in front of them and the promise resolves with their answer.
Ask for input with a form
Form mode carries a message and a requestedSchema: a flat JSON Schema of primitive fields the client renders as a form.
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const server = new McpServer({ name: 'feedback', version: '1.0.0' });
server.registerTool(
'collect-feedback',
{
description: 'Ask the user how something went',
inputSchema: z.object({ topic: z.string() })
},
async ({ topic }, ctx) => {
const result = await ctx.mcpReq.elicitInput({
mode: 'form',
message: `How was ${topic}?`,
requestedSchema: {
type: 'object',
properties: {
rating: { type: 'number', title: 'Rating (1-5)', minimum: 1, maximum: 5 },
comment: { type: 'string', title: 'Comment' }
},
required: ['rating']
}
});
if (result.action !== 'accept') {
return { content: [{ type: 'text', text: `Feedback ${result.action}.` }] };
}
return { content: [{ type: 'text', text: `Recorded: ${JSON.stringify(result.content)}` }] };
}
);result.action records what the end user did — accept, decline, or cancel — and result.content carries the submitted fields on accept only. The SDK validates accepted content against requestedSchema before elicitInput resolves, so the fields you read match the schema you sent.
INFO
On a 2026-07-28 connection elicitInput throws — a handler returns the request instead; see Input required and Protocol versions.
The answer comes from the connected client's elicitation/create handler. Every call on this page uses an in-memory client whose handler stands in for a real host's UI — Handle requests from the server covers the client side in full.
const client = new Client({ name: 'feedback-host', version: '1.0.0' }, { capabilities: { elicitation: { form: {}, url: {} } } });
client.setRequestHandler('elicitation/create', async request => {
if (request.params.mode === 'url') {
// Open request.params.url in the user's browser; answer when they finish.
return { action: 'accept' };
}
// Render request.params.requestedSchema as a form; return what the user typed.
return { action: 'accept', content: { rating: 5, comment: 'Smooth setup' } };
});Call collect-feedback and the elicitation round-trips through that handler inside the one tool call.
const result = await client.callTool({ name: 'collect-feedback', arguments: { topic: 'the new editor' } });
console.log(result.content);The handler resumes with the submitted fields and returns:
[
{
type: 'text',
text: 'Recorded: {"rating":5,"comment":"Smooth setup"}'
}
]Handle every action
Return a distinct result for each action so the model knows whether the end user confirmed, refused, or never answered.
server.registerTool(
'delete-dataset',
{
description: 'Delete a dataset after the user confirms',
inputSchema: z.object({ name: z.string() })
},
async ({ name }, ctx) => {
const result = await ctx.mcpReq.elicitInput({
mode: 'form',
message: `Delete ${name}? This cannot be undone.`,
requestedSchema: {
type: 'object',
properties: { confirm: { type: 'boolean', title: 'Yes, delete it' } },
required: ['confirm']
}
});
switch (result.action) {
case 'accept':
if (result.content?.confirm !== true) {
return { content: [{ type: 'text', text: 'Box left unchecked - nothing deleted.' }] };
}
return { content: [{ type: 'text', text: `Deleted ${name}.` }] };
case 'decline':
return { content: [{ type: 'text', text: 'Declined - nothing deleted.' }] };
case 'cancel':
return { content: [{ type: 'text', text: 'Dismissed - ask again later.' }] };
}
}
);result.content is end-user input: schema-valid, still untrusted — the accept branch checks that the box was actually ticked before acting. Decline the form and the tool answers from the decline branch:
[ { type: 'text', text: 'Declined - nothing deleted.' } ]Prefill a field with a default
Set default on a field and the client renders the form with that value already filled in.
server.registerTool(
'export-report',
{
description: 'Export a report after the user picks a format',
inputSchema: z.object({ name: z.string() })
},
async ({ name }, ctx) => {
const result = await ctx.mcpReq.elicitInput({
mode: 'form',
message: `Export ${name} as which format?`,
requestedSchema: {
type: 'object',
properties: { format: { type: 'string', title: 'Format', enum: ['pdf', 'csv'], default: 'pdf' } },
required: ['format']
}
});
if (result.action !== 'accept') {
return { content: [{ type: 'text', text: `Export ${result.action}.` }] };
}
return { content: [{ type: 'text', text: `Exported ${name} as ${result.content?.format}.` }] };
}
);requestedSchema reaches the client unchanged, default included; the end user submits the prefilled pdf or picks csv. An accept with format left out still returns:
[ { type: 'text', text: 'Exported quarterly-sales as pdf.' } ]INFO
A client that declares elicitation: { form: { applyDefaults: true } } — an SDK flag, not a protocol capability — fills defaulted fields the end user leaves out before the accept reaches your handler; the output above is that case.
Send the end user to a URL
URL mode replaces the form with a browser flow: pass url and a unique elicitationId instead of requestedSchema.
server.registerTool(
'link-account',
{
description: 'Link a billing account through a hosted sign-in flow',
inputSchema: z.object({ provider: z.string() })
},
async ({ provider }, ctx) => {
const result = await ctx.mcpReq.elicitInput({
mode: 'url',
message: `Sign in to ${provider} to link your account`,
url: `https://billing.example.com/connect/${encodeURIComponent(provider)}`,
elicitationId: crypto.randomUUID()
});
if (result.action !== 'accept') {
return { content: [{ type: 'text', text: `Sign-in ${result.action}.` }] };
}
return { content: [{ type: 'text', text: `Linked ${provider}.` }] };
}
);The client opens the URL and answers once the end user finishes there; whatever the page collects — credentials, payment details, API keys — stays in the browser and never crosses the MCP connection. The handler's url branch above accepts, so link-account returns:
[ { type: 'text', text: 'Linked github.' } ]Signal that the URL flow finished
The client learns that the end user finished at the URL from a notifications/elicitation/complete notification that carries the same elicitationId. server.server.createElicitationCompletionNotifier returns the function that sends it — keep it where your callback endpoint can reach it, and pass relatedRequestId so the notification rides the in-flight tool call. Raise the request timeout too — the default is 60 seconds, and a person is on the other end of this one — and forward ctx.mcpReq.signal so a cancelled tool call also cancels the parked elicitation.
const pendingFlows = new Map<string, () => Promise<void>>();
server.registerTool(
'connect-calendar',
{
description: 'Connect a calendar through a hosted consent flow',
inputSchema: z.object({ provider: z.string() })
},
async ({ provider }, ctx) => {
const elicitationId = crypto.randomUUID();
pendingFlows.set(
elicitationId,
server.server.createElicitationCompletionNotifier(elicitationId, { relatedRequestId: ctx.mcpReq.id })
);
try {
const result = await ctx.mcpReq.elicitInput(
{
mode: 'url',
message: `Grant ${provider} calendar access`,
url: `https://calendar.example.com/consent/${encodeURIComponent(provider)}?state=${elicitationId}`,
elicitationId
},
// a person is on the other end (the default timeout is 60 s); the signal
// cancels the parked elicitation if the tool call itself is cancelled
{ timeout: 10 * 60_000, signal: ctx.mcpReq.signal }
);
if (result.action !== 'accept') {
return { content: [{ type: 'text', text: `Consent ${result.action}.` }] };
}
return { content: [{ type: 'text', text: `Connected ${provider}.` }] };
} finally {
pendingFlows.delete(elicitationId);
}
}
);
// The hosted flow redirects back to your server with the id in `state`; that
// endpoint sends the notification.
async function completeFlow(elicitationId: string): Promise<void> {
await pendingFlows.get(elicitationId)?.();
}On the client, hold the elicitation/create answer until the notification names the elicitationId the request carried, and let ctx.mcpReq.signal release it when the server cancels — a timed-out or abandoned flow must not leave the handler waiting.
const finished = new Map<string, () => void>();
client.setNotificationHandler('notifications/elicitation/complete', notification => {
console.log('URL flow finished:', notification.params.elicitationId);
finished.get(notification.params.elicitationId)?.();
finished.delete(notification.params.elicitationId);
});
client.setRequestHandler('elicitation/create', async (request, ctx) => {
if (request.params.mode === 'url') {
// Open request.params.url in the user's browser; answer once the server signals completion.
const { elicitationId } = request.params;
const done = await new Promise<'complete' | 'cancelled'>(resolve => {
finished.set(elicitationId, () => resolve('complete'));
ctx.mcpReq.signal.addEventListener('abort', () => {
finished.delete(elicitationId);
resolve('cancelled');
});
});
return { action: done === 'complete' ? 'accept' : 'cancel' };
}
return { action: 'accept', content: { rating: 5, comment: 'Smooth setup' } };
});The host's own tools/call has the same 60-second default, so the caller raises it as well:
const connecting = client.callTool({ name: 'connect-calendar', arguments: { provider: 'google' } }, { timeout: 10 * 60_000 });Let the callback endpoint run completeFlow with the id from state, and the client logs the notification before the tool result arrives (the id is fresh on every run):
URL flow finished: c9a7bcfc-acc9-494c-8ce5-44c921232ea6
[ { type: 'text', text: 'Connected google.' } ]INFO
This notification exists on 2025-11-25 connections only — the 2026-07-28 input-required flow has no elicitationId and no completion signal; see Protocol versions.
Keep secrets out of forms
Form answers travel back through the client and land in the model's context like any other tool result.
WARNING
Never collect sensitive information — passwords, API keys, payment details — through form elicitation. Use URL mode or an out-of-band flow instead.
Require the elicitation capability
Elicitation only works against a client that declared the elicitation capability — per mode: form, url — when it connected. Against a client without it, elicitInput throws before anything reaches the wire, and the thrown message comes back as an ordinary isError tool result:
{
content: [
{ type: 'text', text: 'Client does not support form elicitation.' }
],
isError: true
}Recap
ctx.mcpReq.elicitInputsends anelicitation/createrequest mid-handler and resolves with the end user's answer.- Form mode carries a
messageand a flat JSON-SchemarequestedSchema; the SDK validates accepted content against it. result.actionisaccept,decline, orcancel;result.contentis present only on accept.defaulton arequestedSchemafield prefills the form; a client that declaresapplyDefaultsfills the field in when the end user leaves it out.- URL mode hands the end user a browser flow — use it for anything sensitive;
createElicitationCompletionNotifierreturns the function that sendsnotifications/elicitation/completeso the client can answer. - Calls against a client that never declared the
elicitationcapability fail before reaching the wire.