# 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.

Source: https://voxgig.com/howto/handle-mcp-elicitation-in-an-agent-client

- Audience: agent-builder
- Level: intermediate
- Languages: typescript
- Verified: 2026-10-21
- Published: 2026-10-19

## 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`](https://github.com/modelcontextprotocol/typescript-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

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Client-side gate on tool annotations](https://modelcontextprotocol.io/specification/2026-07-28/server/tools) | You control the client and want one rule for every server, including servers that never ask | Keyed on hints the server writes about itself, which the spec says to treat as untrusted, so a tool that lies passes | The server knows which calls need a question and which do not |
| [Confirmation tool the model must call first](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#security-considerations) | Clients you do not control, and a confirmation step that should appear in the transcript | The model decides whether to call it, and one that skips it reaches the delete with no question asked unless the server checks a token | The client answers elicitation, which the model cannot skip |
| [Elicitation request from the server](https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation) | The server knows which calls need confirming and what to ask, and the client declares the capability | Absent on a client that never declared it, so the server must fail closed, and the answer is waited for inside the tool call | The 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](https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation) 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]

```js title="client.mjs"
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`](https://py.sdk.modelcontextprotocol.io/client/callbacks/) 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.

```js title="server.mjs"
  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:

```bash
node demo.mjs answers
```

```text output
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`](https://www.jsonrpc.org/specification), 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.

```js title="gate.mjs"
// 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 })
}
```

```bash
node demo.mjs gate
```

```text output
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`](https://nodejs.org/api/test.html). 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.

```bash
node --test elicit.test.mjs
```

```text output
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:

```bash
node pitfall.mjs
```

```text output
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](/howto/require-approval-before-a-delete-tool-call) 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](https://ts.sdk.modelcontextprotocol.io/v2/servers/input-required.html)
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](https://py.sdk.modelcontextprotocol.io/client/callbacks/)
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.

## Related how-tos

- [Add a dry-run mode to an agent's write tools](/howto/add-dry-run-to-agent-write-tools)

- [Upgrade an MCP server to a newer protocol revision](/howto/upgrade-an-mcp-server-to-a-newer-revision)

- [Record an audit trail of every MCP tool call](/howto/audit-trail-for-mcp-tool-calls)

- [Connect a remote MCP server to Claude Desktop](/howto/connect-a-remote-mcp-server-to-claude-desktop)

- [Require human approval before an agent calls a delete tool](/howto/require-approval-before-a-delete-tool-call)

## 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.

[^1]: The [specification](https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation)
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.

[^2]: The [schema reference](https://modelcontextprotocol.io/specification/2025-06-18/schema)
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](https://modelcontextprotocol.io/specification/2025-03-26/changelog) records.

[^3]: The [changelog](https://modelcontextprotocol.io/specification/2026-07-28/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.