[BUG] v2.1.111: ESC during MCP tool call kills all Python stdio MCPs (regression from v2.1.104, 30-line repro) PLEASE FIX NOW!
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Pressing ESC to cancel an in-flight MCP tool call kills the entire MCP
subprocess. The server shows as "failed" in /mcp and all its tools become
unavailable until the user manually runs /mcp to reconnect.
The bug is specific to Python-based stdio MCPs using the FastMCP SDK.
Node-based MCPs (e.g. @playwright/mcp) survive the same ESC gesture in the
same session. Remote HTTP/SSE MCPs (claude.ai Gmail, Calendar) are unaffected
by design.
Reproduced with a 30-line bare-minimum Python MCP server — no business
logic, just await asyncio.sleep(30) in the tool. Signal handlers
(SIGINT/SIGTERM/SIGHUP/SIGPIPE set to custom no-op) do NOT prevent the
kill and never fire — pointing to SIGKILL or stdin-close from Claude Code's
side, which no Python code can intercept.
This is a regression first reported in #47724 (v2.1.105). It persists through
v2.1.106 → v2.1.111. 6+ patch releases, same bug.
What Should Happen?
Per MCP spec, ESC should deliver notifications/cancelled for the in-flight
request ID. The tool call should be marked "rejected by user" and the MCP
subprocess must stay alive, all other tools remain available, no /mcp
reconnect required.
This is exactly how v2.1.104 behaves. Same server code, same ESC gesture, same
result: tool cancelled, MCP still connected.
Error Messages/Logs
The following MCP servers have disconnected: esc-test
The following deferred tools are no longer available (their MCP server
disconnected)
1 MCP server failed · /mcp
Nothing on the server-side stderr — no Python traceback, no KeyboardInterrupt,
no CancelledError, no custom signal-handler log. The process is killed
externally.
Steps to Reproduce
- Install the MCP SDK:
```bash
pip install "mcp[cli]>=1.0"
- Save as ~/esc_test.py:
"""Minimal FastMCP Python server — reproduces ESC-kill regression."""
import asyncio, os, signal, sys
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("esc-test")
print(f"[esc-test] pid={os.getpid()} booting", file=sys.stderr, flush=True)
@mcp.tool()
async def sleep_long(seconds: int = 30) -> str:
"""Sleep for N seconds. ESC mid-call should cancel but NOT kill the
MCP."""
try:
await asyncio.sleep(seconds)
return f"slept {seconds}s"
except BaseException as e:
print(f"[esc-test] cancelled with {type(e).__name__}: {e}",
file=sys.stderr, flush=True)
raise
@mcp.tool()
async def ping() -> str:
"""Health check. If this still works after ESC, the MCP survived."""
return f"alive pid={os.getpid()}"
def _diag(signum, frame):
try: name = signal.Signals(signum).name
except Exception: name = f"sig-{signum}"
print(f"[esc-test] received {name} ({signum}); ignored", file=sys.stderr,
flush=True)
if __name__ == "__main__":
for s in ("SIGINT","SIGTERM","SIGHUP","SIGPIPE"):
sig = getattr(signal, s, None)
if sig is not None:
try: signal.signal(sig, _diag)
except Exception: pass
mcp.run()
- Register:
claude mcp add esc-test -- python3 ~/esc_test.py
- Start a fresh Claude Code session and run /mcp — confirm esc-test · ✔
connected.
- Prompt Claude: "call the sleep_long tool from the esc-test MCP with
seconds=30".
- As soon as the tool call starts, press ESC once to cancel.
- Run /mcp — esc-test · ✘ failed.
- Prompt Claude: "call the ping tool from the esc-test MCP" — fails with "MCP
server has disconnected". Requires manual /mcp to recover.
Expected (v2.1.104 behavior): esc-test remains connected after ESC. ping
returns alive pid=<N> immediately with the same PID as before. No reconnect
needed.
Claude Model
Opus
Is this a regression?
Yes, this worked in a previous version
Last Working Version
2.1.104
Claude Code Version
2.1.111 (Claude Code)
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Terminal.app (macOS)
Additional Information
Follow-up to #47724 (same bug, now reproduced cleanly on v2.1.111 with a
minimal 30-line server).
**What I ruled out across ~8 hours of debugging**:
1. **Signal handling** — `signal.signal(SIGINT/SIGTERM/SIGHUP/SIGPIPE, no-op)`
installed before `mcp.run()` does NOT prevent the kill. The handler never
fires — no stderr log. Rules out Python-level signal termination; points to
`SIGKILL` or stdin close.
2. **Event-loop teardown** — wrapping `mcp.run()` in `while True: try:
mcp.run() except BaseException: continue` does NOT save the process. The
subprocess actually exits. The restart loop never triggers. Rules out uncaught
exceptions / `KeyboardInterrupt`.
3. **MCP scope / bucket** — all equally affected:
- `--mcp-config <file>` (shown as "Built-in MCP" in `/mcp`) — dies
- auto-loaded `.mcp.json` at cwd ("Project MCP") — dies
- `claude mcp add --scope local` (".claude.json" project-scope, "Local
MCP") — dies
- `claude mcp add --scope user` ("User MCPs") — dies
4. **Python MCP SDK version** — tested with `mcp[cli]==1.26.0` and `1.27.0`.
Same behavior.
5. **Lifespan / tool count / imports** — bare FastMCP with just `mcp.run()` +
one `asyncio.sleep` tool (no lifespan, 2 tools, no imports beyond stdlib +
`mcp`) dies identically to a production server with 40+ tools, lifespan,
daemon threads, and heavy imports.
6. **Language comparison, same session**:
- `@playwright/mcp@latest` (Node) — survives ESC during `navigate` tool,
stays connected.
- Any Python FastMCP — dies on ESC, no exceptions, no logs.
Happy to pair-debug with an Anthropic engineer on this — I have the minimal
▎ repro plus 8 hours of bisection data already narrowed down.
**Impact**: any Python MCP with long tool calls (mesh coordination, wait
loops, data pipelines, polling) is unusable on 2.1.105+ without the user
manually `/mcp`-reconnecting after every cancellation. Workaround: pin Claude
Code to `2.1.104`. This issue has 9 comments on GitHub. Read the full discussion on GitHub ↗