Skip to content

[BUG] Streamable HTTP MCP: tools/call stays "still running" forever when the server dies mid-call — the client reconnects the GET stream but never fails the pending POST #104166

Closed as duplicate

Description

@0xdhx

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:

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.log

Observed (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

  1. github-actions commented on Oct 22, 2026

    @github-actions

    Found 1 possible duplicate issue:

    1. [BUG] Streamable HTTP MCP: in-flight tool call Promise orphaned forever on connection drop #55992

    This issue will be automatically closed as a duplicate in 3 days.

    • If your issue is a duplicate, please close it and 👍 the existing issue instead
    • To prevent auto-closure, add a comment or 👎 this comment

    🤖 Generated with Claude Code

  2. added
    duplicateThis issue or pull request already exists
    and removed
    bugSomething isn't working
    has reproHas detailed reproduction steps
    platform:linuxIssue specifically occurs on Linux
    on Oct 25, 2026
  3. github-actions commented on Oct 25, 2026

    @github-actions

    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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    duplicateThis issue or pull request already exists

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions