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)

Aperta
#89,858 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub
area:agent-sdk area:mcp bug has repro platform:windows
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

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.