You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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.
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.
The SDK answers for the absent handler. In the desktop app's bundle (Agent SDK 0.3.234), processControlRequest:
if(e.request.subtype===`elicitation`){letn=e.requestif(this.onElicitation){…returnr}return{action: `decline`}// ← no handler, no prompt, positive refusal}if(e.request.subtype===`request_user_dialog`){if(this.onUserDialog){…returnn}uL(`[Query] No onUserDialog handler for request_user_dialog (kind=…) — staying silent so a capable client (or the worker's park deadline) settles it`)returns$// 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
Any MCP server whose tool calls ctx.elicit(...) (FastMCP) or issues elicitation/create.
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.
Call the tool. The server receives {"action": "decline"} with no UI shown anywhere.
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.
Preflight Checklist
What's wrong
When Claude Code is driven over
--input-format stream-json, an MCP server'selicitation/createis forwarded to the controlling SDK client as a control request. A client that registered noonElicitationcallback gets an unconditionalfrom the SDK. The MCP spec defines
declineas "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)
Read at the consumer
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.stream-jsonthe elicitation never reaches the Ink queue thatregisterElicitationHandlerfeeds.handleElicitationforwards it to the controlling client as a control request,{subtype: "elicitation", …}, and usescancelfor its own failure path.0.3.234),processControlRequest:onElicitationoccurs five times in that bundle and every occurrence is inside the vendored SDK (field declaration, thehasCallbackspredicate, the constructor assignment, the use site above, and thequery()options destructure). No application call site supplies one.Two adjacent branches, one lossless and one lossy.
Why it is not recoverable server-side
ElicitResultcarriesactionandcontentand nothing else. FastMCP'sContext.elicitmapsdeclineonto a payload-freeDeclinedElicitation(), so even an_metaa client might attach would not reach the tool body.clientInfois identical in REPL andstream-jsonmode. 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_dialogbranch immediately below it: with noonElicitationregistered, 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,
cancelis strictly better thandecline: the spec defines it as "User dismissed without making an explicit choice", which does not assert a user decision that did not happen.handleElicitationalready usescancelfor 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
ctx.elicit(...)(FastMCP) or issueselicitation/create.--input-format stream-jsonunder a controlling client that passes noonElicitationtoquery()— the desktop app is one such client.{"action": "decline"}with no UI shown anywhere.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.195and 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 everyonElicitationoccurrence located, and the cost measured.Method
Read in the shipped bundles, plus one field observation. No
initializeand noelicitation/createwas 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.