anthropics / anthropics/claude-code
MCP elicitation is answered `decline` by a controlling client that never drew a prompt (desktop app, --input-format stream-json, SDK 0.3.234)
- Lingua principale
- Python
- Stelle
- 145k
- Fork
- 23.1k
- Metriche di merge delle PR
- Metriche PR in attesa
Descrizione
### Preflight Checklist
- [x] I have searched existing issues. Closest matches, **all closed, none on the merits**: **#41292** (same root cause, VS Code extension, closed DUPLICATE), **#62319** (form-mode in the interactive TUI, closed NOT_PLANNED — by a bot, for inactivity, with an explicit invitation to refile), **#48164** (url-mode, an explicit RPC error rather than a silent non-accept, so a different failure shape). This report is the **desktop app** as the controlling client, which none of those covers.
- [x] This is a single bug report.
### 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
```js
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`:
```js
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.
Guida per i contributori
Nessuna guida per i contributori indicizzata per questo repository
Direzione di ricerca
Start at the SDK's processControlRequest elicitation branch and compare it with the adjacent request_user_dialog branch; trace how handleElicitation uses cancel and how stream-json forwards control requests. Reproduce with an MCP server calling ctx.elicit(...) under a controlling client without onElicitation, then verify the server no longer receives an unconditional decline when no prompt is shown.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Valutazione
- Stack tecnologico
- javascript
- Ambito
- api, cli
- Tipo di issue
- Bug
- Difficoltà
- 4/5
- Tempo stimato
- 3-5 giorni
- Stato di attività
- Attiva
- Chiarezza
- Abbastanza chiara
- Idoneità per principianti
- 48/100