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)
- 主要言語
- Python
- スター
- 145k
- フォーク
- 23.1k
- PR マージ指標
- PR 指標を取得中
説明
### 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.
コントリビューションガイド
このリポジトリのコントリビューションガイドは索引されていません
調査の方向性
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.
索引モデルが issue の本文から書いたものです。
評価
- 技術スタック
- javascript
- 領域
- api, cli
- issue の種類
- バグ
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 活発さ
- 活発
- 明瞭さ
- おおむね明確
- 初心者へのやさしさ
- 48/100