Skip to content

Fix #277: MCP tool calls honor abort_controller (ESC-cancel)#308

Open
ericleepi314 wants to merge 1 commit into
fix/issue-276-web-abortfrom
fix/issue-277-mcp-abort
Open

Fix #277: MCP tool calls honor abort_controller (ESC-cancel)#308
ericleepi314 wants to merge 1 commit into
fix/issue-276-web-abortfrom
fix/issue-277-mcp-abort

Conversation

@ericleepi314

Copy link
Copy Markdown
Collaborator

Closes #277
Closes #171 (duplicate)

Stacked on #307 (← #306#305#304). Merge in order; GitHub retargets automatically.

Summary

ESC during a long-running MCP tool hung until the MCP request timeout (5 min default). The pending JSON-RPC future now has a real cancellation path:

  • McpClient._send_request / call_tool accept an abort_signal. The abort listener fires on the aborting thread (TUI ESC handler) and hops onto the request loop via call_soon_threadsafe(future.cancel) — the call unblocks in ~0.1s.
  • MCP notifications/cancelled is sent best-effort with the in-flight requestId so a compliant server stops the work — bounded to 2s so a fully wedged transport (the likely cause of the hang being escaped) can't block the unblock path.
  • Cleanup is exhaustive: a finally pops the pending entry and removes the listener across all exit paths — success, timeout, abort, genuine task-cancel, and a transport.send that raises or is cancelled mid-await (the send was moved inside the try after review caught the leak).
  • Receive-loop hardening: a response racing the abort cleanup lands on an already-cancelled future; resolving it would raise InvalidStateError and kill the loop (nuking every pending request). It's now skipped via a done() check.
  • Tool wrapper: re-raises AbortError past its except Exception so the dispatch layer renders the user-cancel message instead of a generic tool error; session-expiry retry threads the signal too.

Scope note: list_tools/list_resources/initialize are startup-time, never on the interactive ESC path — deliberately not signal-threaded.

Test plan

  • 9 new tests in tests/test_mcp_abort.py: abort unblocks <2s on a hanging transport; cancellation notification carries the right requestId (and id=None); pending cleaned; pre-aborted sends nothing; listener removed on normal completion; send-failure cleans listener+pending; receive loop survives a late response for a cancelled future; wrapper re-raises AbortError
  • All 531 MCP-related tests pass (5 session-expiry stubs updated for the new kwarg)
  • Full suite on the stack: 7794 passed, 0 failed, 5 skipped
  • Critic review loop: APPROVE after 1 revision round (send-inside-try listener leak + bounded notification both came out of it, empirically verified by the reviewer)

🤖 Generated with Claude Code

ESC during a long-running MCP tool hung until the request timeout
(5 min default) — the JSON-RPC future had no cancellation path.

- McpClient._send_request/call_tool accept an abort signal; the
  listener hops onto the request loop via call_soon_threadsafe to
  cancel the pending future, so ESC from the UI thread unblocks the
  call in ~0.1s
- on abort, a bounded (2s) best-effort MCP notifications/cancelled is
  sent so compliant servers stop the work; a wedged transport cannot
  block the unblock path
- listener + pending-entry cleanup runs in a finally that also covers
  a send that raises or is cancelled mid-await
- the receive loop skips responses for already-cancelled futures
  (set_result on a cancelled future would kill the loop and nuke every
  pending request)
- the tool wrapper re-raises AbortError past its except-Exception so
  dispatch renders the user-cancel message, and resolves the signal
  via getattr for duck-typed contexts

Closes #277, closes #171

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant