[BUG] Remote (Streamable HTTP) MCP form elicitation never reaches the client: no dialog, no `Elicitation` hook, server times out at -32001
Preflight Checklist
- [x] I have searched existing issues. Closest matches are #62319, #79174 (form-mode elicitation auto-declined, logged as "print mode") and #84207 (instant "User cancelled", intermittent, renders sometimes). This is a distinct failure mode: nothing is sent back to the server at all — the request silently times out — and the
Elicitationhook never fires, which localizes the loss to before the client's elicitation handling. Also related: #38755 (elicitation hooks not triggered under--input-format stream-json). - [x] This is a single bug report
- [x] I am using the latest version (
2.1.226)
What's Wrong?
A remote (Streamable HTTP) MCP server sends a valid elicitation/create with mode: "form" during a tool call. Claude Code advertised elicitation.form at initialize, so the server enters the elicit path — then:
- No dialog is rendered.
- No response of any kind is sent back. The server waits its full 20s budget and fails with
MCP error -32001: Request timed out. - The
Elicitationhook never fires, even though hooks are provably live at that instant.
Unlike #62319 / #79174 / #84207, this is not an auto-decline or a cancel. Those all deliver some result to the server promptly. Here the client is simply silent.
What Should Happen?
Per the docs: "elicitation dialogs appear automatically when a server requests them." A form dialog should render, or — failing that — the request should be declined promptly with a distinguishable result so the server can fall back, and the Elicitation hook should fire so the situation is observable.
Error Messages/Logs
Server side, matching to the millisecond on every attempt:
MCP add_note elicitation 실패 (team <redacted>, deal #<redacted>): MCP error -32001: Request timed out
POST /mcp duration 20019ms status 200
20019ms is our ELICITATION_TIMEOUT_MS = 20_000 expiring — not an early return.
Client side, hook probe log (see repro step 4):
=== LOADPROBE PostToolBatch 2026-08-10T05:56:13Z <- control fired, same batch as the tool call
(no "=== Elicitation" line)
(no "=== Notification" line)
Steps to Reproduce
- Connect a remote MCP server over Streamable HTTP whose write tool calls
elicitInputwithmode: "form"(a single required boolean field, in our case). - Confirm Claude Code advertised the capability:
getClientCapabilities()?.elicitation?.formis truthy server-side. (Our server has a separate branch for the unsupported case and it is not taken — so the capability was declared.) - Invoke the tool. Approve the normal permission prompt when it appears.
- Instrument the client to distinguish "never received" from "received but not rendered":
"Elicitation": [
{ "matcher": "", "hooks": [ { "type": "command",
"command": "{ printf '=== Elicitation %s\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"; cat; } >> /tmp/probe.log" } ] }
],
"Notification": [
{ "matcher": "elicitation_dialog|elicitation_complete|elicitation_response", "hooks": [ { "type": "command",
"command": "{ printf '=== Notification %s\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"; cat; } >> /tmp/probe.log" } ] }
],
"PostToolBatch": [
{ "hooks": [ { "type": "command",
"command": "{ printf '=== LOADPROBE %s\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"; } >> /tmp/probe.log" } ] }
]
The Elicitation hook emits no stdout, so it registers no decision and does not suppress the dialog. PostToolBatch is a positive control: it fires per batch, so a LOADPROBE line carrying the same timestamp as the tool call proves the hook machinery was loaded and running at that moment. Without that control, "no log line" is ambiguous.
Result: LOADPROBE fires (including in the tool call's own batch). Elicitation and Notification never fire. 20s later the server reports -32001.
Environment
| | |
|---|---|
| Claude Code | 2.1.226 |
| Platform | macOS (Darwin 25.5.0) |
| MCP transport | Remote, Streamable HTTP (https://…/mcp) |
| Elicitation mode | form, one required boolean field |
Ruled Out
Each varied independently; the failure is unchanged:
- Permission mode — reproduces in
autoandacceptEdits. The English permission prompt for the same tool call renders correctly inacceptEdits, so interactive prompting works in that very session; only the elicitation path is silent. - Version — first seen on
2.1.224, reproduces identically on2.1.226. - Dead SSE stream — the standalone
GET /mcpstream was open (05:19:02 → 05:29:02) when the call fired at05:28:01, and the POST itself was open for its whole 20s. Both candidate delivery channels were live. - A hook swallowing it — no
Elicitationhook was configured for the original failures; the one added later is log-only and emits no stdout. - Server not actually sending — the server's capability-unsupported branch returns immediately with a different message and is not taken; the 20019ms duration shows it sent and waited.
Additional Information
Possibly related and visible in the same logs, though not the cause here: every SSE GET /mcp stream terminates at ~599,9xx ms with Truncated response body, which is Cloud Run's 600s request cap. Since that stream is a delivery channel for server→client requests, an elicitation landing in the gap between stream death and client reconnect would fail with an identical signature. Our reproduction had a live stream, so this is a second, independent failure window rather than an explanation of this one.
One consequence worth flagging for triage: because the client returns nothing rather than a decline, a server cannot distinguish "user ignored the dialog" from "client never showed it." Both collapse into the same timeout, so fail-closed write gates become unusable rather than merely degraded.
3 Comments
That positive control is useful. It makes me suspect the failure happens before rendering: the client advertises
elicitation.format init, but when the request arrives it may not have a live UI + response channel to attach it to.I work on BitFun, and we hit a similar edge in our MiniApp runtime. We record an interactive request as pending UI state before the tool waits. If no connected client can render it, the RPC returns an explicit unavailable/decline immediately instead of hanging. A regression that only accepts “dialog + response” or immediate structured unsupported — never 20 seconds of silence — would cover this well.
Thanks — that framing helps, and I think our data narrows it one step further.
One part of the hypothesis our repro rules out. In the failing call the client did have a live interactive UI and response channel: the same
add_notetool call rendered Claude Code's own English permission prompt, and the user approved it, in the same session, seconds before the elicitation was sent. So it isn't that there was no connected client able to render — the prompting machinery demonstrably worked for that very call. Combined with theElicitationhook never firing (against aPostToolBatchpositive control in the same batch), the loss localizes to before anything that would look for a UI to attach to: the request doesn't reach the client's elicitation handling at all.A second client shows the same server-side signature. I re-ran the identical flow against the same remote server from Codex CLI (
0.147.0) with write scope, and the server loggedMCP error -32001at 20020ms vs 20019ms for Claude Code — same capability advertisement (elicitation.form), same silence, same error. I want to be careful not to overclaim: that run wascodex exec, i.e. headless, and a headless client having no dialog to show is expected. But it makes your proposed contract the exact right ask — a headless client should either not advertiseelicitation.form(so the server fast-fails on its unsupported branch) or return a structured decline immediately. Twenty seconds of silence is neither. So the contract violation is observable across two clients, not one.Strong +1 on the regression shape you described. "Dialog + response, or immediate structured unsupported, never silence" is the property that actually matters, because of the observability consequence: with nothing returned, a server cannot distinguish user ignored the dialog from client never showed it. Both collapse into the same timeout, which means a fail-closed write gate built on elicitation isn't degraded — it's unusable, and un-debuggable from the server side. The only signal we had was that the server's own capability-unsupported branch wasn't taken.
Where we're headed, for whatever it's worth to triage. We've stopped treating elicitation as a delivery channel and are moving the confirmation out of band — persisting the tainted write server-side as a pending approval with a frozen payload, returning "awaiting approval #id" to the model, and having a human approve it in our own web UI. That's still in design review on our side, but it's structurally close to the pending-UI-state approach you described in BitFun — two teams landing on the same shape is probably itself a signal about the channel. We'd happily go back to elicitation as progressive enhancement once it's dependable; I've left the hook probe in place as a fix detector.
Evidence that this is fixed as of 2.1.228: we run a hosted MCP server (Elicitly) whose
elicit_doctortool fires a live form-mode elicitation probe. Against Claude Code CLI 2.1.228 on macOS over remote streamable HTTP, the dialog now renders and the answer round-trips —{"attempted":true,"action":"accept","latencyMs":8254,"data":"ok","verdict":"working"}— reproduced twice today (8.3s and 21.3s, both human-answered). The same probe on 2.1.223 auto-answered cancel in ~1.5s with no dialog, matching this issue's report. Verbatim before/after fingerprints (handshake echo + probe verdicts): https://www.elicitly.ai/docs/elicitation/support-matrix/ — happy to re-test candidate builds if useful.