claude-code - 💡(How to fix) Fix MCP elicitation queue leaks on abort — multi-elicit tool calls silently stall [1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
anthropics/claude-code#46340Fetched 2026-04-11 06:22:51
View on GitHub
Comments
0
Participants
1
Timeline
3
Reactions
0
Participants
Timeline (top)
labeled ×3

Error Message

  • 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. MCP error -32001: Request timed out` (tool call 4) ⚠ same — no UI, no error surfaced, eventually timeout

Root Cause

File: 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:

And there's one secondary cleanup path via useEffect in src/components/mcp/ElicitationDialog.tsx:186-199:

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, 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).

Code Example

(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

---

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 })
})

---

useEffect(() => {
  if (!signal) return;
  const handleAbort = () => {
    onResponse('cancel');
  };
  if (signal.aborted) {
    handleAbort();
    return;
  }
  signal.addEventListener('abort', handleAbort);
  return () => { signal.removeEventListener('abort', handleAbort); };
}, [signal, onResponse]);

---

const abortController = new AbortController();
this._requestHandlerAbortControllers.set(request.id, abortController);

---

// 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,
);

---

handler result: { action: 'cancel' }
queue length AFTER abort: 1
leaked entry signalAborted: true

---

handler result: { action: 'cancel' }
queue length AFTER abort: 0
leaked entry signalAborted: undefined

---

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' })
   }
RAW_BUFFERClick to expand / collapse

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, 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:

And there's one secondary cleanup path via useEffect in src/components/mcp/ElicitationDialog.tsx:186-199:

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, 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 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).

extent analysis

TL;DR

The proposed fix involves modifying the onAbort callback to remove the entry from the AppState.elicitation.queue when the signal is aborted, ensuring cleanup regardless of whether the ElicitationDialog mounts for this entry.

Guidance

  • Modify the onAbort callback in elicitationHandler.ts to filter out the current entry from the AppState.elicitation.queue when the signal is aborted.
  • Verify that the queue is empty after the signal is aborted by checking the state.elicitation.queue.length in the reproducible test case.
  • Consider adding a regression test to ensure the fix works correctly in different scenarios, such as multiple requests being queued and one being aborted out-of-order.
  • Review the ElicitationDialog component to ensure it handles the case where the dialog is suppressed and the signal is aborted, to prevent any potential issues.

Example

The proposed fix can be implemented by modifying the onAbort callback as follows:

 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' })
   }

Notes

The fix assumes that the serverName and requestId are unique identifiers for each entry in the AppState.elicitation.queue. If this is not the case, additional logic may be needed to correctly identify and remove the entry.

Recommendation

Apply the proposed workaround by modifying the onAbort callback to remove the entry from the AppState.elicitation.queue when the signal is aborted. This fix ensures that the queue is properly cleaned up, preventing subsequent elicitations from being blocked by zombie entries.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING