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)

Offen
#89,858 0 Kommentare 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen
area:agent-sdk area:mcp bug has repro platform:windows
Vorherrschende Sprache
Python
Sterne
145k
Forks
23.1k
PR-Merge-Kennzahlen
PR-Kennzahlen ausstehend

Beschreibung

### 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.

Beitragsleitfaden

Für dieses Repository ist kein Beitragsleitfaden indexiert

Rechercherichtung

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.

Vom Indexierungsmodell aus dem Issue-Text verfasst.

Bewertung

Tech-Stack
javascript
Bereich
api, cli
Issue-Typ
Bug
Schwierigkeit
4/5
Geschätzter Aufwand
3-5 Tage
Aktivitätsstatus
Aktiv
Klarheit
Größtenteils klar
Anfängerfreundlichkeit
48/100

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.