Skip to content

MCP elicitation is answered decline by a controlling client that never drew a prompt (desktop app, --input-format stream-json, SDK 0.3.234) #89858

Description

@r3vs

Preflight Checklist

What's wrong

When Claude Code is driven over --input-format stream-json, an MCP server's elicitation/create is forwarded to the controlling SDK client as a control request. A client that registered no onElicitation callback gets an unconditional

return { action: "decline" }

from the SDK. The MCP spec defines decline as "User explicitly declined the action" — so a server receives a positive statement about a human who was never shown anything.

The SDK's own adjacent branch handles the identical situation without inventing an answer, which is what makes this look like an oversight rather than a design choice.

Observed (field)

  • Claude Code inside the Claude desktop app, Windows 11, 2026-08-19/20.
  • An MCP server whose decision tool elicits a single-select enum. Every call returned a declined elicitation. No prompt was rendered at any point, while the human was active in the same conversation and answered ~25 questions through the assistant's own question UI in the same minutes.
  • 23 elections had to be recorded through a separate local CLI instead.

Read at the consumer

  1. The desktop app spawns the CLI as claude.exe --output-format stream-json --input-format stream-json …walked from the live process tree, so this is the observed launch and not a docs claim.
  2. Under stream-json the elicitation never reaches the Ink queue that registerElicitationHandler feeds. handleElicitation forwards it to the controlling client as a control request, {subtype: "elicitation", …}, and uses cancel for its own failure path.
  3. The SDK answers for the absent handler. In the desktop app's bundle (Agent SDK 0.3.234), processControlRequest:
if (e.request.subtype === `elicitation`) {
  let n = e.request
  if (this.onElicitation) {  return r }
  return { action: `decline` }          // ← no handler, no prompt, positive refusal
}
if (e.request.subtype === `request_user_dialog`) {
  if (this.onUserDialog) {  return n }
  uL(`[Query] No onUserDialog handler for request_user_dialog (kind=…) — staying silent so a
      capable client (or the worker's park deadline) settles it`)
  return s$                              // suppressControlResponse
}

onElicitation occurs five times in that bundle and every occurrence is inside the vendored SDK (field declaration, the hasCallbacks predicate, the constructor assignment, the use site above, and the query() options destructure). No application call site supplies one.

Two adjacent branches, one lossless and one lossy.

Why it is not recoverable server-side

ElicitResult carries action and content and nothing else. FastMCP's Context.elicit maps decline onto a payload-free DeclinedElicitation(), so even an _meta a client might attach would not reach the tool body. clientInfo is identical in REPL and stream-json mode. There is no capability shape, era, or field that separates a human refused from nobody was asked — and a timing threshold is a guess, not a signal.

For a server that treats a refusal as load-bearing — ours records who supplied a decision, and a fabricated refusal is indistinguishable from a real one — this collapses two opposite facts onto one value with no way back.

Suggested fix, in the SDK's own idiom

Make the elicitation branch behave like the request_user_dialog branch immediately below it: with no onElicitation registered, do not answer. Let a capable client or the park deadline settle it, and log the same way. A server then sees a request that was never answered, which is the truth.

If a reply is required by the transport, cancel is strictly better than decline: the spec defines it as "User dismissed without making an explicit choice", which does not assert a user decision that did not happen. handleElicitation already uses cancel for its own failure path.

A third option, if the shape can be extended: let the client state that no handler exists, so a server can distinguish declined from undeliverable rather than inferring it.

Reproduction

  1. Any MCP server whose tool calls ctx.elicit(...) (FastMCP) or issues elicitation/create.
  2. Run Claude Code with --input-format stream-json under a controlling client that passes no onElicitation to query() — the desktop app is one such client.
  3. Call the tool. The server receives {"action": "decline"} with no UI shown anywhere.
  4. Run the same server against the Claude Code REPL: a radio list is drawn and the human answers.

Related, and why this is not a duplicate of them

#62319 was closed by a bot for inactivity, not resolved, over a thread carrying independent confirmations through build 2.1.195 and a bundle reading that meets this one from the other side: the session binds the control-protocol / SDK elicitation handler rather than the interactive one, and the "Elicitation request received in print mode" log is emitted ungated at registration time — so it is a mislabel, not a mode check, and seeing it does not mean the session is headless. #41292 is the same root cause on the VS Code extension and was closed as a duplicate. What this report adds is a different first-party client (the desktop app), the launch identified by walking the process tree rather than inferred, the SDK version pinned with every onElicitation occurrence located, and the cost measured.

Method

Read in the shipped bundles, plus one field observation. No initialize and no elicitation/create was captured on the wire — every claim above is either observed behaviour of a running system or read at the function that consumes the value, marked per item, never mixed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions