How-to › Use AI to do the integration

How to handle MCP elicitation requests in your agent client#

Let an MCP server ask the person before a destructive tool runs: declare the capability, render the schema as a form, and answer accept, decline, or cancel.

Audience
Agent builder
Level
intermediate
Topic
Guard agents and add approvals
Languages
TypeScript
Verified

Read first: Require human approval before an agent calls a delete tool

Your MCP server wants to ask the person before it deletes a ticket, and the protocol has a request for that. Your client never declared the capability, so the question fails inside the tool call before anyone sees it. Or the handler you copied returns accept to whatever schema arrives, so the delete goes through on an answer nobody gave.

What you get

You will end up with a client that renders a server’s elicitation request as a form, answers accept, decline, or cancel, and proves that only an explicit yes deletes. This is for you if your MCP client runs tools with side effects.

Short answer

Declare elicitation in the client’s capabilities and register a handler for ElicitRequestSchema. Render the request’s message and requestedSchema as a form, then return accept with content that matches the schema, decline, or cancel. On the server, treat anything but an accepted confirm: true as a no, and catch the error a client without the capability raises. Bound the wait with a timeout, because the question blocks inside the tool call.

You will need

Node 22 or later, an MCP client connected to a server over stdio or Streamable HTTP, and a tool worth confirming. Verified 2026-10-21 against Node 22.22.0, @modelcontextprotocol/sdk 1.32.1, and zod 4.6.7. The client and the server run in one process over the SDK’s in-memory transport, so no port and no model was involved, and the person’s answers are scripted. That SDK speaks protocol revision 2025-11-25. Revision 2026-07-28 moves the question out of the call, and the last failure mode below says how. A pause that outlives the process belongs to an approval gate rather than to this request.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Client-side gate on tool annotationsYou control the client and want one rule for every server, including servers that never askKeyed on hints the server writes about itself, which the spec says to treat as untrusted, so a tool that lies passesThe server knows which calls need a question and which do not
Confirmation tool the model must call firstClients you do not control, and a confirmation step that should appear in the transcriptThe model decides whether to call it, and one that skips it reaches the delete with no question asked unless the server checks a tokenThe client answers elicitation, which the model cannot skip
Elicitation request from the serverThe server knows which calls need confirming and what to ask, and the client declares the capabilityAbsent on a client that never declared it, so the server must fail closed, and the answer is waited for inside the tool callThe answer may take hours, which is a durable approval gate’s job

Elicitation puts the question where the knowledge is: the server knows that delete_ticket is destructive and list_tickets is not, and asks only for the first. The cost is coverage, because a client that never declared the capability gets no question, and the server finds out when elicitInput throws. The annotation gate covers every server at the price of trusting what each server says about itself. A confirmation tool leaves the decision to the model, which may not take it.

Declare the capability and render the request as a form

An empty elicitation object declares form mode, which the specification treats as { form: {} }. A form request carries a message and a requestedSchema, and the schema is restricted to a flat object of primitive fields, so one renderer covers every server.1

export function renderForm(params) {
  const lines = [params.message]
  const required = new Set(params.requestedSchema?.required ?? [])
  for (const [name, field] of Object.entries(params.requestedSchema?.properties ?? {})) {
    const label = field.title ?? name
    lines.push(`  [${field.type}] ${label}${required.has(name) ? ' (required)' : ''}: ${field.description ?? ''}`.trimEnd())
  }
  return lines
}

export function buildClient(decide, { capability = true } = {}) {
  const client = new Client(
    { name: 'desk-agent', version: '1.0.0' },
    // An empty elicitation object declares form mode. Leave the key out and the server's
    // elicitInput throws before any question is asked.
    { capabilities: capability ? { elicitation: {} } : {} },
  )
  if (capability) {
    client.setRequestHandler(ElicitRequestSchema, async (request, extra) => {
      const { params } = request
      if (params.mode === 'url') return { action: 'decline' }
      // A real client blocks here on a dialog. The answer is one of three actions, and only
      // accept carries content, which must match the requested schema. The signal aborts when
      // the server stops waiting, and the SDK drops any answer after that, so close the dialog.
      return decide(renderForm(params), params, extra.signal)
    })
  }
  return client
}

Only accept carries content, and the server’s SDK checks it against the schema before the tool sees it. In the Python SDK the same handler is the elicitation_callback passed to Client, an asynchronous function of the request context and the request parameters that returns an ElicitResult built from params.message and params.requested_schema.

Ask before the delete, and read every other answer as no

The server asks with server.server.elicitInput, inside the tool handler, and waits.

  server.registerTool('delete_ticket', {
    description: 'Delete a ticket by id. The user is asked to confirm first.',
    inputSchema: { id: z.string() },
    annotations: { destructiveHint: true, idempotentHint: false },
  }, async ({ id }) => {
    if (!store.has(id)) return text(`no ticket ${id}`, true)
    let answer
    try {
      // This waits, inside the tool call, until the person answers or the timeout passes.
      answer = await server.server.elicitInput(
        { message: `Delete ticket ${id}, "${store.title(id)}"? This cannot be undone.`, requestedSchema: CONFIRM_SCHEMA },
        { timeout: elicitTimeoutMs },
      )
    } catch (err) {
      // No capability, a timeout, or an answer that failed the schema: fail closed, and say why.
      return text(`not deleted: ${reason(err)}`, true)
    }
    if (answer.action !== 'accept') return text(`not deleted: the user ${answer.action === 'decline' ? 'declined' : 'cancelled'}`)
    if (answer.content?.confirm !== true) return text('not deleted: the user answered no')
    store.delete(id)
    return text(`deleted ${id}`)
  })

Six clients answer the same question:

node demo.mjs answers
the form the client renders from the request:
  | Delete ticket t3, "BUY CHEAP WATCHES NOW"? This cannot be undone.
  |   [boolean] Delete this ticket (required): A deleted ticket cannot be recovered.
client accepts with confirm: true
  tool result: deleted t3
  tickets left: t1 t2
client accepts with confirm: false
  tool result: not deleted: the user answered no
  tickets left: t1 t2 t3
client declines
  tool result: not deleted: the user declined
  tickets left: t1 t2 t3
client cancels
  tool result: not deleted: the user cancelled
  tickets left: t1 t2 t3
client accepts with a string where a boolean was asked for
  tool result: not deleted: MCP error -32602: Elicitation response content does not match requested schema: data/confirm must be boolean (isError)
  tickets left: t1 t2 t3
client without the elicitation capability
  tool result: not deleted: this client cannot ask the user (isError)
  tickets left: t1 t2 t3

One answer deletes. A decline, a cancel, and an accept with confirm: false each get their own wording, so the model learns whether the person refused or wandered off. The string where a boolean was asked for never reaches the tool: the SDK rejects it with -32602, JSON-RPC’s Invalid params. The last client declared no capability, and elicitInput threw Client does not support form elicitation. before any question was sent, which the handler turns into a refusal with isError set. Without that catch, the capability a client forgot to declare would surface as a crashed tool call.

Gate on annotations when the server will not ask

A server that never asks still describes its tools, and a client can act on the description.

// A client-side approval gate keyed on tool annotations. The spec's defaults decide the silent
// case: readOnlyHint defaults to false and destructiveHint to true, so a tool that says nothing
// about itself is asked about, and only a tool that claims to be read-only is not.
export function needsApproval(tool) {
  const a = tool?.annotations ?? {}
  if (a.readOnlyHint === true) return false
  return a.destructiveHint !== false
}

export async function callWithGate(client, name, args, ask) {
  const { tools } = await client.listTools()
  const tool = tools.find((t) => t.name === name)
  if (needsApproval(tool) && !(await ask(name, args))) return { refused: true, content: [], isError: false }
  return client.callTool({ name, arguments: args })
}
node demo.mjs gate
client-side gate from annotations:
  list_tickets   readOnlyHint=true  destructiveHint=-     runs without asking
  delete_ticket  readOnlyHint=-     destructiveHint=true  asks the user first
  purge_spam     readOnlyHint=true  destructiveHint=-     runs without asking
  the gate asked about: delete_ticket
  purge_spam claimed readOnlyHint: true, so the gate let it run: purged t3
  tickets left: t1 t2

The defaults do the work on tools that say nothing: readOnlyHint defaults to false and destructiveHint to true, so silence is treated as destructive.2 The third tool is the limit. purge_spam deletes and claims to be read-only, and the gate believed it, because annotations are whatever the server chose to write. The specification says clients must treat them as untrusted unless they come from trusted servers. That makes the gate a second layer behind elicitation, or a first layer in front of servers you trust.

Check it worked

Fourteen tests run the client and the server over the in-memory transport under node:test. Each answer is checked against the store, so a regression that reports a delete without doing it, or does it without the answer, fails a test.

node --test elicit.test.mjs
1..14
# tests 14
# suites 0
# pass 14
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1100.972703

When it goes wrong

The person walked away, and the tool call waits with them. Elicitation is a request inside a request, so the connection carries an open call for as long as the form is open. The SDK’s default request timeout is sixty seconds on either side, read from its source, and the server here passes its own:

node pitfall.mjs
attempt 1: the client shows a form and nobody answers; the server allows 300 ms
  tool result: not deleted: no answer before the timeout (isError)
attempt 2: the client shows a form and nobody answers; the server allows 300 ms
  the client closes form 2: McpError: MCP error -32001: Request timed out
  tool result: not deleted: no answer before the timeout (isError)
tickets left: t1 t2 t3
forms still open: 1; closing the connection
  the client closes form 1: AbortError: This operation was aborted

When the server gives up, it sends the client notifications/cancelled, and the SDK aborts the signal the handler was given, so pass that signal to the dialog and close it there. The SDK this page pins skips a cancellation whose request id is 0, the first request a server sends on a connection, so the first form stays open until the connection closes. Keep the timeout short and the question quick. An approval that may take an afternoon belongs to a gate that stores the paused run and resumes it from another process.

The question never arrives on a 2026-07-28 connection. That revision replaced server-initiated requests with multi round-trip requests. A tool call that needs an answer returns an InputRequiredResult carrying the elicitation, and the client answers in inputResponses on a retry of the same call, so nothing waits inside it.3 The v2 TypeScript SDK’s input-required page says elicitInput throws on such a request and a handler returns inputRequired(...) instead, while the client registers its elicitation/create handler once and the SDK feeds it from the retry. The Python SDK’s callbacks page says a default Client negotiates that path, so ctx.elicit fails before the callback runs, and mode="legacy" pins the old one. Check which revision your session negotiated before you debug the handler.

The server deletes on accept alone, because it compared the action and not the field. An accepted form with confirm: false is not a yes, and neither is an accept with no content, which the SDK passes to the tool without checking it against the schema. Compare the field, not the action.

When not to do this

Do not ask for a password, an API key, or a card number in a form. The specification says servers must not use form mode for secrets and credentials, and gives URL mode for them. There the client shows the target host and asks for consent before opening it, and the data never passes through the client.

Do not hold a tool call open for an answer that may take hours. A long timeout turns a confirmation into a connection the server has to keep, and a restart on either side loses the question. Hand that case to an approval gate with a store behind it.

Do not auto-accept. A handler that returns accept for every schema turns every server’s safeguard into a formality, and the server cannot tell. When no person is present, return decline.

Do not treat an answer as authorization. The person saying yes does not make the caller allowed to delete; the server checks the caller’s rights before the question and after it.

Do not let annotations from a server you do not trust decide the gate on their own.

Last verified

Verified 2026-10-21 against Node 22.22.0, @modelcontextprotocol/sdk 1.32.1, and zod 4.6.7. Every output block is what the command preceding it printed. The client and the server ran in one process over the in-memory transport, and the person’s answers were scripted. The behavior of the 2026-07-28 revision, the v2 TypeScript SDK, and the Python SDK is taken from the specification and the SDK documentation, and was not run.

Footnotes

  1. The specification gives the button for each action: Submit, OK, or Confirm for accept, and Reject, Decline, or No for decline. Cancel is closing the dialog, clicking outside it, pressing Escape, or the browser failing to load. A server that reads cancel therefore cannot tell a person who left from a page that did, and is advised to handle dismissal by prompting again later. ↩︎ Back to text

  2. The schema reference lists the defaults under ToolAnnotations: readOnlyHint false, destructiveHint true, idempotentHint false, and openWorldHint true. Its note says that all of them are hints, not guaranteed to describe the tool faithfully, and extends the warning to the descriptive properties, title among them. The field a person reads is on the list with the rest. Annotations themselves arrived one revision earlier, in 2025-03-26, as its changelog records. ↩︎ Back to text

  3. The changelog for the revision records the multi round-trip pattern as SEP-2322. The same list removes notifications/elicitation/complete and the elicitationId field, because a completion signal “no longer fit the protocol” once the client learns the outcome by retrying. Both had been introduced in the revision before it, so the two were in the specification for eight months. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.