MCP elicitation queue leaks on abort — multi-elicit tool calls silently stall

Resolved 💬 2 comments Opened Apr 10, 2026 by aryasaatvik Closed Jun 25, 2026

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 /mcp reconnect — which tears down the client, discards AppState.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 the ElicitationDialog's onResponse callback (user clicks a button)
  • [src/screens/REPL.tsx:4745](../src/screens/REPL.tsx) — queue.slice(1) inside onWaitingDismiss (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):

  1. Peer sends notifications/cancelled for this request id → _oncancelcontroller.abort().
  2. Transport closes → _onclose aborts all in-flight controllers.
  3. (Indirectly) The AbortSignal passed via extra.signal is plumbed into Claude Code's [wrapFetchWithTimeout](../src/services/mcp/client.ts#L492) via parentSignal. 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:4735 and :4745 only. No other cleanup.
  • onAbort does not call setAppState: direct read of elicitationHandler.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.signal is an AbortController.signal per 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).

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗