Repository navigation
Description
TL;DR
When a Streamable HTTP MCP server dies while a tools/call is in flight, Claude Code never fails that call. It logs Tool '<name>' still running (Ns elapsed) indefinitely, even though it has already noticed the peer go away and reconnected the GET stream.
The server answers the tools/call POST with a text/event-stream response, and that stream ends before the result frame arrives (reset, clean close, or the whole server process dying). The pending request is never rejected. It is only failed when the client later closes its own transport for some other reason; in the repro below that was process teardown at 119 s, and in the original 240 s runs at 238 s. When that happens the client raises its existing "connection closed before the tool call was answered" error and applies its retry rule. CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT is the only bound, and unset it did not fire within 240 s.
Reproduced on 2.1.307 and 2.1.304, using unmodified release binaries, Linux x64.
Prior reports
These were all closed by bots and locked, and none was triaged:
- [BUG] MCP tool calls hang indefinitely with no way to cancel byt itself #44006, "MCP tool calls hang indefinitely with no way to cancel": closed as stale (
has repro,area:mcp). - [BUG] Streamable HTTP MCP: in-flight tool call Promise orphaned forever on connection drop #55992, "Streamable HTTP MCP: in-flight tool call Promise orphaned forever on connection drop": auto-closed as a duplicate of [BUG] MCP tool calls hang indefinitely with no way to cancel byt itself #44006.
- Claude Code 2.1.143 silently hangs MCP tool calls after SSE drop — client logs the drop but does not fail or reconnect #60061, "2.1.143 silently hangs MCP tool calls after SSE drop": closed as stale (
has repro,area:mcp).
This report adds a dependency-free local repro (one Python stdlib file, loopback only), results on two current versions, and one new observation: the client reconnects the GET stream within about a second of the server dying, so it does detect the dead peer, but it leaves the POST pending anyway.
Repro
repro_mcp.py: on the first tools/call per tool, it opens an SSE response and then simulates the server dying, resetting that stream and every open GET stream. Later calls answer normally.
#!/usr/bin/env python3
# repro_mcp.py PORT - Streamable-HTTP MCP server. The first tools/call per tool opens an
# SSE response, then the "server dies": that stream and every open GET stream are reset.
import json, socket, struct, sys, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
TOOLS = [
{"name": "poke", "description": "Changes state.", "inputSchema": {"type": "object", "properties": {}}},
{"name": "peek", "description": "Reads state.", "inputSchema": {"type": "object", "properties": {}},
"annotations": {"readOnlyHint": True}},
]
faulted, gets = set(), []
def say(*a):
print(time.strftime("%H:%M:%S"), *a, flush=True)
def rst(conn):
try:
conn.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
conn.close()
except OSError:
pass
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, format, *args):
pass
def sse(self):
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Mcp-Session-Id", "s1")
self.end_headers()
self.wfile.write(b": open\n\n")
self.wfile.flush()
def do_GET(self):
say("GET stream opened")
self.sse()
gets.append(self.connection)
while self.connection in gets:
time.sleep(0.2)
self.close_connection = True
def reply(self, code, obj=None):
body = b"" if obj is None else json.dumps(obj).encode()
self.send_response(code)
if obj is not None:
self.send_header("Content-Type", "application/json")
self.send_header("Mcp-Session-Id", "s1")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
msg = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}")
mid, method, params = msg.get("id"), msg.get("method"), msg.get("params") or {}
say("POST", method, params.get("name", ""))
if mid is None:
return self.reply(202)
result = {}
if method == "initialize":
result = {"protocolVersion": params.get("protocolVersion", "2025-06-18"),
"capabilities": {"tools": {}}, "serverInfo": {"name": "repro", "version": "0"}}
elif method == "tools/list":
result = {"tools": TOOLS}
elif method == "tools/call":
if params["name"] not in faulted:
faulted.add(params["name"])
self.sse()
time.sleep(0.5)
say(" -> server dies: resetting the response stream and", len(gets), "GET stream(s)")
for c in list(gets):
gets.remove(c)
rst(c)
self.close_connection = True
return rst(self.connection)
result = {"content": [{"type": "text", "text": params["name"] + " ok"}]}
self.reply(200, {"jsonrpc": "2.0", "id": mid, "result": result})
ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever()python3 repro_mcp.py 8765 &
echo '{"mcpServers":{"repro":{"type":"http","url":"http://127.0.0.1:8765/mcp"}}}' > mcp.json
timeout 120 claude -p "Call the tool mcp__repro__poke once with no arguments. If it errors, do not call it again; reply with the error text." \
--strict-mcp-config --mcp-config mcp.json --allowedTools mcp__repro__poke --debug-file debug.log
grep -E 'Calling MCP tool|still running|connection closed|session expired|Retrying' debug.logObserved (2.1.307)
Server:
19:59:42 POST server/discover
19:59:42 POST initialize
19:59:42 POST notifications/initialized
19:59:42 GET stream opened
19:59:42 POST tools/list
19:59:45 POST tools/call poke
19:59:46 -> server dies: resetting the response stream and 1 GET stream(s)
19:59:47 GET stream opened
Client (debug.log):
2026-10-22T00:59:45.940Z [DEBUG] MCP server "repro": Calling MCP tool: poke
2026-10-22T01:00:15.941Z [DEBUG] MCP server "repro": Tool 'poke' still running (30s elapsed)
2026-10-22T01:00:45.940Z [DEBUG] MCP server "repro": Tool 'poke' still running (59s elapsed)
2026-10-22T01:01:15.937Z [DEBUG] MCP server "repro": Tool 'poke' still running (89s elapsed)
2026-10-22T01:01:41.784Z [DEBUG] MCP server "repro": HTTP connection closed after 119s (cleanly)
2026-10-22T01:01:41.786Z [DEBUG] MCP server "repro": MCP session expired during tool call (connection closed), clearing connection cache for re-initialization
The client reopened the GET stream one second after the server died (19:59:47), but the POST stayed pending for the whole run. The 01:01:41 close is timeout 120 killing the process, not anything the server did. In the longer runs below (240 s cap, server named fake), teardown came early enough for the tool result to reach the model, and it was:
MCP server "fake" connection closed before the tool call was answered. The tool may have run: check whether it did before running it again.
Matrix
A larger version of the same server was run with one claude -p turn per case, and the model made exactly one tool_use in each SSE case, so any second arrival at the server is a client retry. Each SSE case hung until teardown (about 238 s under a 240 s cap):
| fault | 2.1.304 | 2.1.307 |
|---|---|---|
| SSE response ends cleanly (FIN), no result | hangs; at teardown re-sends every tool (2 arrivals) | hangs; at teardown re-sends only readOnlyHint / idempotentHint tools |
| SSE response reset (RST) | same | same |
| server dies: response stream and GET stream reset | same; GET reconnects at once, POST stays pending | same |
| plain JSON response, socket reset | 1 arrival; ECONNRESET returned to the model at once |
same |
| HTTP 503 | 1 arrival; error returned at once | same |
server dies, with CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT=20000 |
not run | aborted at 30 s: sent no response or progress for 30s; aborting |
2.1.307's change ("only read-only or idempotent tools are now retried", related to #98466) is visible in the first row and works as described. It only acts once the transport has closed, though, which is the step this report is about.
Expected
When the HTTP response stream that carries a pending request's result ends, by EOF or by error, before that request's JSON-RPC response has arrived, reject the pending request promptly. It could use the same connection closed before the tool call was answered error the client already raises at transport close, so the existing retry rule applies unchanged. If the server sent SSE event ids, one Last-Event-ID resumption attempt (per the Streamable HTTP spec) could come first.
A lower default idle timeout is not a substitute. A timeout cannot tell a dead stream from a slow but healthy tool, so any value either cuts off legitimate long silent calls or leaves this hang in place.
Environment
- Claude Code 2.1.307 and 2.1.304 (native, linux-x64)
- Python 3, standard library only, for the repro server
- Model for the repro turns:
claude-haiku-4-5-20251001(the model is irrelevant to the hang)
Activity
github-actions commentedon Oct 22, 2026
Found 1 possible duplicate issue:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
github-actions commentedon Oct 25, 2026
This issue has been automatically closed as a duplicate of #55992.
If this is incorrect, please re-open this issue or create a new one.
🤖 Generated with Claude Code