MCP elicitation queue leaks on abort — multi-elicit tool calls silently stall
TL;DR
client.setRequestHandler(ElicitRequestSchema, ...) in src/services/mcp/elicitationHandler.ts adds an entry to AppState.elicitation.queue but the onAbort callback never removes it. If an elicitation request is aborted after the entry has been queued (e.g. upstream fetch timeout, transport close, peer-cancel), the entry becomes a zombie. Subsequent elicitations from the same server stack up behind it in the queue and never surface to the user. Recovery requires /mcp reconnect.
Symptoms
- MCP servers that produce multiple elicitation requests in quick succession (e.g. a sandboxed code executor calling multiple non-SAFE tools) intermittently stop showing approval prompts.
- Sometimes the first few approvals surface correctly, then new ones silently fail to appear.
- When it fails, there is no error in the terminal UI — the tool call simply appears to hang, and eventually the upstream MCP server logs a request timeout and returns an error.
- Recovering is only possible by
/mcpreconnect — which tears down the client, discardsAppState.elicitation.queue, and rebuilds fresh. - More frequent with workloads that batch multiple non-SAFE operations into one tool call.
Concretely what the user sees in a multi-elicit workload after the leak begins:
(tool call 1) ✓ approval UI shown, clicked accept, worked
(tool call 2) ✓ approval UI shown, clicked accept, worked
(tool call 3) ⚠ approval UI does NOT appear. ~60 seconds later:
MCP server logs `elicitInput failed — falling back to cancel.
MCP error -32001: Request timed out`
(tool call 4) ⚠ same — no UI, no error surfaced, eventually timeout
(tool call 5) ⚠ same
...
(user: `/mcp` reconnect)
(tool call 6) ✓ approval UI shown again
Root cause
File: [src/services/mcp/elicitationHandler.ts](../src/services/mcp/elicitationHandler.ts), lines 114–153.
const response = new Promise<ElicitResult>(resolve => {
const onAbort = () => {
resolve({ action: 'cancel' })
// ⬆ resolves Promise with cancel, but does NOT remove the
// entry from AppState.elicitation.queue that is pushed below.
}
if (extra.signal.aborted) {
onAbort()
return
}
setAppState(prev => ({
...prev,
elicitation: {
queue: [
...prev.elicitation.queue,
{
serverName,
requestId: extra.requestId,
params: request.params,
signal: extra.signal,
waitingState,
respond: (result: ElicitResult) => {
extra.signal.removeEventListener('abort', onAbort)
/* ... */
resolve(result)
},
},
],
},
}))
extra.signal.addEventListener('abort', onAbort, { once: true })
})
The only places that mutate elicitation.queue to remove entries:
- [
src/screens/REPL.tsx:4735](../src/screens/REPL.tsx) —queue.slice(1)inside theElicitationDialog'sonResponsecallback (user clicks a button) - [
src/screens/REPL.tsx:4745](../src/screens/REPL.tsx) —queue.slice(1)insideonWaitingDismiss(user dismisses a URL-mode waiting state)
And there's one secondary cleanup path via useEffect in [src/components/mcp/ElicitationDialog.tsx:186-199](../src/components/mcp/ElicitationDialog.tsx):
useEffect(() => {
if (!signal) return;
const handleAbort = () => {
onResponse('cancel');
};
if (signal.aborted) {
handleAbort();
return;
}
signal.addEventListener('abort', handleAbort);
return () => { signal.removeEventListener('abort', handleAbort); };
}, [signal, onResponse]);
This fires onResponse('cancel') when the dialog mounts against an already-aborted signal, which DOES remove the zombie. But it only runs when the dialog actually mounts. If the dialog is suppressed (e.g. isPromptInputActive at [REPL.tsx:2068](../src/screens/REPL.tsx), or another dialog has focus priority) at the moment the signal aborts, the useEffect never runs for that entry, and the zombie persists.
Because the dialog renders queue[0] keyed on serverName + ':' + requestId, the first zombie blocks all subsequent entries from being shown — they sit at queue[1+] and never reach the head until the user responds to the zombie (which is a no-op, since its respond callback resolves an already-resolved Promise).
What causes the signal to abort in the first place?
extra.signal is the per-request abort signal created at protocol.js:314-315 in the MCP SDK:
const abortController = new AbortController();
this._requestHandlerAbortControllers.set(request.id, abortController);
It aborts under three conditions (protocol.js:169-176, 259-262):
- Peer sends
notifications/cancelledfor this request id →_oncancel→controller.abort(). - Transport closes →
_oncloseaborts all in-flight controllers. - (Indirectly) The
AbortSignalpassed viaextra.signalis plumbed into Claude Code's [wrapFetchWithTimeout](../src/services/mcp/client.ts#L492) viaparentSignal. When the per-POST 60s fetch cutoff fires, the outgoing POST aborts and the inbound incoming signal may abort as a downstream effect.
In practice, the most common trigger I observed was condition (1): an MCP server whose server.server.elicitInput(...) call hits the SDK's DEFAULT_REQUEST_TIMEOUT_MSEC = 60000 and sends a notifications/cancelled for the original request. The MCP SDK is well-behaved and correctly cancels the client handler — but the elicitation queue entry is orphaned because onAbort forgets to remove it.
Reproduction (standalone, no full Claude Code needed)
Save the following as repro.mjs and run with node repro.mjs:
// Mirrors the exact pattern in elicitationHandler.ts:114-153
let state = { elicitation: { queue: [] } };
const setAppState = (f) => { state = f(state); };
async function handleElicitationRequest({ serverName, request, extra }) {
return new Promise((resolve) => {
const onAbort = () => {
resolve({ action: "cancel" });
};
if (extra.signal.aborted) {
onAbort();
return;
}
setAppState((prev) => ({
...prev,
elicitation: {
queue: [
...prev.elicitation.queue,
{
serverName,
requestId: extra.requestId,
params: request.params,
signal: extra.signal,
respond: (result) => {
extra.signal.removeEventListener("abort", onAbort);
resolve(result);
},
},
],
},
}));
extra.signal.addEventListener("abort", onAbort, { once: true });
});
}
const controller = new AbortController();
const promise = handleElicitationRequest({
serverName: "executor",
request: { params: { message: "Approve?", requestedSchema: {} } },
extra: { signal: controller.signal, requestId: "req-1" },
});
// Simulate the signal aborting after the entry is in the queue
// (e.g. upstream notifications/cancelled, transport close, fetch timeout).
await new Promise((r) => setTimeout(r, 10));
controller.abort();
const result = await promise;
console.log("handler result:", result);
console.log("queue length AFTER abort:", state.elicitation.queue.length);
console.log(
"leaked entry signalAborted:",
state.elicitation.queue[0]?.signal.aborted,
);
Expected output (buggy):
handler result: { action: 'cancel' }
queue length AFTER abort: 1
leaked entry signalAborted: true
Expected output after fix:
handler result: { action: 'cancel' }
queue length AFTER abort: 0
leaked entry signalAborted: undefined
Proposed fix
Make onAbort remove the entry from the queue, so cleanup is guaranteed regardless of whether ElicitationDialog ever mounts for this entry:
const response = new Promise<ElicitResult>(resolve => {
const onAbort = () => {
+ // Remove the entry from the queue on abort. Otherwise dead entries
+ // pile up behind any suppressed dialog (e.g. while isPromptInputActive)
+ // and block subsequent elicitations from surfacing.
+ setAppState(prev => ({
+ ...prev,
+ elicitation: {
+ queue: prev.elicitation.queue.filter(
+ e => !(
+ e.serverName === serverName &&
+ e.requestId === extra.requestId
+ ),
+ ),
+ },
+ }))
resolve({ action: 'cancel' })
}
The ElicitationDialog useEffect cleanup (lines 186-199) becomes a redundant but harmless secondary path.
Suggested regression test
A test that runs the handler function with a mock setAppState and asserts the queue is empty after the signal is aborted post-queue-push (both the simple case and a case where multiple requests are queued and one is aborted out-of-order). The standalone repro above is 80% of the test already.
Blast radius / edge cases
- Multi-elicit tool calls: most likely to trigger, since subsequent elicits pile up behind the first zombie.
- Tool calls where the MCP server itself has a long-running upstream (e.g. network API, LLM call) and occasionally hits the MCP SDK's 60s default
DEFAULT_REQUEST_TIMEOUT_MSEC— each such timeout leaks an entry. - Transport drops: one transport reconnect can drop many in-flight elicitations at once; after reconnect, the old queue entries are all zombies.
- URL-mode elicitations (
mode: 'url'): the entry also sticks around for phase 2 ("waiting state"). Same cleanup gap applies on abort.
Where I confirmed each claim
- Queue mutation sites (grep across
src/):src/screens/REPL.tsx:4735and:4745only. No other cleanup. onAbortdoes not callsetAppState: direct read ofelicitationHandler.ts:114-122.- Dialog mount is conditional on
focusedInputDialog === 'elicitation'which is suppressed by other dialogs / input state:src/screens/REPL.tsx:2034, 2068. extra.signalis anAbortController.signalper incoming request:@modelcontextprotocol/sdk/shared/protocol.js:314-315.- Transport close aborts all active request handlers:
protocol.js:259-262. - Peer cancellation aborts the matching controller:
protocol.js:169-176. - Claude Code advertises elicitation capability with bare
{}:client.ts:994-999. - Standalone repro reproduces the leak deterministically (see above).
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗