--- shape: how-to --- # 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. ```ts source="../../examples/guides/servers/elicitation.examples.ts#registerTool_elicitForm" 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](./input-required.md) and [Protocol versions](../protocol-versions.md). ::: 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](../clients/server-requests.md) covers the client side in full. ```ts source="../../examples/guides/servers/elicitation.examples.ts#Client_elicitationHandler" 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. ```ts source="../../examples/guides/servers/elicitation.examples.ts#callTool_collectFeedback" 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. ```ts source="../../examples/guides/servers/elicitation.examples.ts#registerTool_elicitActions" 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. ```ts source="../../examples/guides/servers/elicitation.examples.ts#registerTool_elicitDefault" 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`. ```ts source="../../examples/guides/servers/elicitation.examples.ts#registerTool_elicitUrl" 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. ```ts source="../../examples/guides/servers/elicitation.examples.ts#createElicitationCompletionNotifier_connectCalendar" const pendingFlows = new Map Promise>(); 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 { 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. ```ts source="../../examples/guides/servers/elicitation.examples.ts#setNotificationHandler_elicitationComplete" const finished = new Map 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: ```ts source="../../examples/guides/servers/elicitation.examples.ts#callTool_connectCalendar_timeout" 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](./input-required.md) flow has no `elicitationId` and no completion signal; see [Protocol versions](../protocol-versions.md). ::: ## 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.elicitInput` sends an `elicitation/create` request mid-handler and resolves with the end user's answer. - Form mode carries a `message` and a flat JSON-Schema `requestedSchema`; the SDK validates accepted content against it. - `result.action` is `accept`, `decline`, or `cancel`; `result.content` is present only on accept. - `default` on a `requestedSchema` field prefills the form; a client that declares `applyDefaults` fills the field in when the end user leaves it out. - URL mode hands the end user a browser flow — use it for anything sensitive; `createElicitationCompletionNotifier` returns the function that sends `notifications/elicitation/complete` so the client can answer. - Calls against a client that never declared the `elicitation` capability fail before reaching the wire.