[BUG] HTTP MCP session recovery runs two initializes concurrently, leaking sessions (and failing the retried call on servers that limit sessions)

Status Open
Reported on v2.1.280
Maintainer reply None cached
Activity 3 comments · opened Sep 24, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code

What's Wrong?

When a Streamable HTTP server answers 404 to a stale Mcp-Session-Id (the server restarted), two recovery paths in the client start at the same time and don't coordinate:

  • the transport: MCP session expired (server no longer recognizes session ID), triggering reconnection → reconnecting (attempt 1/5)
  • the tool-call wrapper, 3 ms later: Retrying tool 'ping' after session recovery

Each sends its own initialize. The retried call runs on one of the two sessions. The reconnect's connection is then dropped (The operation was aborted. → Terminal connection error 1/3), and reconnect attempt 2 initializes a third session a second later, which the client keeps. The first two are never DELETEd.

What that costs a server:

  1. Every server leaks two sessions per recovery. A server that holds per-session state (a DB connection, an upstream client) keeps it until its own idle timeout.
  2. A server that limits sessions can fail the retried call. On ours, which allows one session and evicts the old one on a new initialize, the two concurrent initializes evict each other. When the tool-call path loses, the model gets MCP server "swing" is not connected for a server that is up. It lost 2 out of 2 restarts on our Java server. On the Python probe below, the tool path happened to win, so this part depends on timing.

Related, but not the same bug: #95018 (the recovery retry covers only tools/call) and #94273 (a 404 during re-initialize disconnects the server for good).

What Should Happen?

One recovery per 404: a single initialize that both the transport and the pending tool call wait on and then share, and a DELETE for any session the client abandons.

Error Messages/Logs

Server log from the probe below (sessions allowed to accumulate), one restart:

16:07:13.295 POST tools/call session=0145dca5-…
16:07:13.295   -> 404 unknown session
16:07:13.300 POST server/discover session=None
16:07:13.304 POST server/discover session=None
16:07:13.306 POST initialize session=None
16:07:13.306   new session f868e00a-…            ← never used again, never DELETEd
16:07:13.307 POST initialize session=None
16:07:13.307   new session 49166b50-…            ← carries the retried tools/call, then abandoned
16:07:13.308 POST notifications/initialized session=f868e00a-…
16:07:13.309 POST notifications/initialized session=49166b50-…
16:07:13.318 POST tools/call session=49166b50-…
16:07:14.323 POST server/discover session=None
16:07:14.325 POST initialize session=None
16:07:14.325   new session a965246b-…            ← reconnect attempt 2; the client keeps this one
16:07:14.326 POST notifications/initialized session=a965246b-…
16:07:14.329 POST tools/list session=a965246b-…

The next restart repeats it exactly: three initializes, no DELETE.

Client debug log (--debug-file) for the same sequence, against our one-session server where the retried call failed:

13:03:07.129Z MCP server "swing": MCP session expired (server no longer recognizes session ID), triggering reconnection
13:03:07.131Z MCP server "swing": http transport closed — reconnecting (attempt 1/5)
13:03:07.132Z MCP server "swing": Tool 'swing_snapshot' failed after 0s: Connection closed
13:03:07.132Z MCP server "swing": MCP session expired during tool call (connection closed), clearing connection cache for re-initialization
13:03:07.134Z MCP server "swing": Retrying tool 'swing_snapshot' after session recovery
13:03:07.234Z MCP server "swing": Successfully connected (transport: http) in 103ms
13:03:07.236Z MCP server "swing": HTTP connection dropped after 0s uptime
13:03:07.236Z MCP server "swing": Connection error: The operation was aborted.
13:03:07.236Z MCP server "swing": Terminal connection error 1/3
13:03:07.237Z [ERROR] MCP server "swing" Failed to fetch tools: Not connected
13:03:07.240Z MCP server "swing": Reconnect attempt 1 did not connect (failed); next in 1000ms
13:03:07.258Z [ERROR] MCP server "swing" Connection failed (404): Error POSTing to endpoint: {… "only accepts one client at a time, and a new client just connected — so this session was closed." …}
13:03:07.259Z [WARN] [Stall] tool_dispatch_end tool=mcp_tool toolUseId=… outcome=error durationMs=209
13:03:08.241Z MCP server "swing": Reconnect attempt 2/5

Steps to Reproduce

Python 3 standard library only. probe.py is a Streamable HTTP server with sessions and one tool, ping, that logs every request. --single makes a new initialize evict the existing sessions, like a server that serves one client at a time.

<details><summary><code>probe.py</code></summary>

#!/usr/bin/env python3
"""Minimal Streamable HTTP MCP server with sessions and one tool, `ping`; logs every request.
usage: probe.py PORT LOGFILE [--single]   --single: a new initialize evicts every existing session"""
import json, sys, time, uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

PORT, LOG, SINGLE = int(sys.argv[1]), sys.argv[2], "--single" in sys.argv
sessions = set()

def log(msg):
    with open(LOG, "a") as f:
        f.write(time.strftime("%H:%M:%S") + ".%03d " % (time.time() * 1000 % 1000) + msg + "\n")

class H(BaseHTTPRequestHandler):
    def reply(self, status, body=None, sid=None):
        data = json.dumps(body).encode() if body is not None else b""
        self.send_response(status)
        if body is not None: self.send_header("Content-Type", "application/json")
        if sid: self.send_header("Mcp-Session-Id", sid)
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def do_POST(self):
        msg = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
        sid, method, rid = self.headers.get("Mcp-Session-Id"), msg.get("method"), msg.get("id")
        log(f"POST {method} session={sid}")
        if method == "initialize":
            if SINGLE and sessions:
                log(f"  evicting {sorted(sessions)}"); sessions.clear()
            new = str(uuid.uuid4()); sessions.add(new); log(f"  new session {new}")
            return self.reply(200, {"jsonrpc": "2.0", "id": rid, "result": {
                "protocolVersion": msg["params"]["protocolVersion"],
                "capabilities": {"tools": {}}, "serverInfo": {"name": "probe", "version": "1"}}}, new)
        if sid is None:
            return self.reply(400, {"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": "Method not found"}})
        if sid not in sessions:
            log("  -> 404 unknown session")
            return self.reply(404, {"jsonrpc": "2.0", "id": rid, "error": {"code": -32001, "message": "Session not found"}})
        if rid is None: return self.reply(202)
        if method == "tools/list":
            result = {"tools": [{"name": "ping", "description": "Returns pong",
                                 "inputSchema": {"type": "object", "properties": {}}}]}
        elif method == "tools/call":
            result = {"content": [{"type": "text", "text": "pong from " + sid}]}
        else:
            return self.reply(200, {"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": "Method not found"}})
        self.reply(200, {"jsonrpc": "2.0", "id": rid, "result": result})

    def do_DELETE(self):
        sid = self.headers.get("Mcp-Session-Id"); log(f"DELETE session={sid}")
        sessions.discard(sid); self.reply(200)

    def do_GET(self): self.reply(405)
    def log_message(self, *a): pass

log(f"started on {PORT}" + (" (single-session)" if SINGLE else ""))
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()

</details>

restart.sh kills the probe and starts a fresh one on the same port, which drops every session:

#!/usr/bin/env bash
cd "$(dirname "$0")"
[ -f probe.pid ] && kill "$(cat probe.pid)" 2>/dev/null
sleep 0.5
nohup python3 probe.py 18763 server.log $PROBE_FLAGS >/dev/null 2>&1 & echo $! > probe.pid
sleep 0.5; echo restarted
chmod +x restart.sh
echo '{"mcpServers":{"probe":{"type":"http","url":"http://127.0.0.1:18763/mcp"}}}' > mcp.json
./restart.sh      # PROBE_FLAGS=--single ./restart.sh for the one-session variant
claude -p --strict-mcp-config --mcp-config mcp.json --debug-file client.log \
  --allowedTools mcp__probe__ping "Bash(./restart.sh)" \
  "Do these steps strictly one at a time, continuing even if a step fails: 1. call mcp__probe__ping  2. run ./restart.sh  3. call mcp__probe__ping  4. run ./restart.sh  5. call mcp__probe__ping. Then print one line per step with its result or error verbatim."
kill "$(cat probe.pid)"
grep -E 'initialize|new session|DELETE|404' server.log

Expected: one initialize per restart, plus a DELETE for anything abandoned. Actual: three initializes per restart and no DELETE, every time.

Claude Model

Other (Haiku in the repro; the model has no effect on this)

Is this a regression?

I don't know. On 2.1.280 we also saw several initializes per recovery, but the retried call succeeded in every run we made against the one-session server.

Last Working Version

_No response_

Claude Code Version

2.1.281 (Claude Code)

Platform

Anthropic API

Operating System

Ubuntu/Debian Linux

Terminal/Shell

Non-interactive (claude -p)

Additional Information

Found while building an in-process MCP server for Swing desktop apps. The app restarts often during development, so this recovery path runs all the time.

View original on GitHub ↗

3 Comments

stonianua · 15 days ago

The dual-path recovery race you logged looks real: transport reconnect and the tool-call wrapper both treat a 404-on-stale-Mcp-Session-Id as “start initialize,” and nothing serializes them. Your probe timeline (two initializes within ~1 ms, then a third on reconnect attempt 2, never a DELETE) matches that shape, especially under --single where each new session evicts the previous one.

A few things that have helped isolate / soften this while waiting on a single shared recovery latch:

  1. Confirm both paths fire on one 404 — Keep your probe log. On the client, --debug-file around one restart should show both triggering reconnection / reconnecting (attempt 1/5) and Retrying tool '…' after session recovery within a few ms of the same 404. If only one line appears, it is a different failure mode than the race.
  1. Serialize recovery on the server side as a diagnostic — Temporarily reject a second initialize while one is in flight (or hold a 50–100 ms gate). If the client’s retried tools/call then reliably lands on the sole surviving session, that points at missing client-side coordination rather than a flaky transport.
  1. Workaround for one-session / low-session servers — After a deploy/restart, avoid an immediate tool call that can race the transport reconnect: wait until tools/list (or a successful reconnect log line) completes once, then call. That does not fix the leak of abandoned sessions, but it often stops the “MCP server is not connected” loss on the retried call.
  1. Repro bundle for maintainers — Claude Code 2.1.281, your probe.py + restart.sh, server log with the three new session lines and zero DELETE, plus the client debug excerpt with both recovery lines. Desired client behavior matches what you wrote: one initialize per 404 that both paths await, and DELETE for any session the client abandons.

Happy to compare notes if you try the in-flight-initialize gate on the probe and see whether the retried call stops losing under --single.

richardpowellus · 14 days ago

Another consequence of this race, seen on 2.1.286: the retried tools/call reaches the server and completes, but the reconnect closes the connection it is waiting on, so the client reports MCP server "<name>" session expired. For a non-idempotent tool the model retries and the action happens twice. Ours was a "create draft" tool: two drafts.

Client MCP log (Streamable HTTP, server restarted ~12 min earlier):

02:16:40.035 Calling MCP tool: create_draft
02:16:40.046 Connection error: Error POSTing to endpoint: unknown session
02:16:40.046 MCP session expired (server no longer recognizes session ID), triggering reconnection
02:16:40.048 http transport closed — reconnecting (attempt 1/5)
02:16:40.050 Tool 'create_draft' failed after 0s: Connection closed
02:16:40.053 Retrying tool 'create_draft' after session recovery
02:16:40.140 Connection error: The operation was aborted.
02:16:40.148 Calling MCP tool: create_draft          <- retry, on a fresh session
02:16:40.149 Reconnect attempt 1 did not connect (failed); next in 1000ms
02:16:41.157 Reconnect attempt 2/5
02:16:41.158 Tool 'create_draft' failed after 1s: Connection closed   <- surfaced to the model as "session expired"
02:16:41.354 Reconnected (attempt 2)

Server side, the retried call (request id 2 on the fresh session) ran from 02:16:40.15 to 02:16:43.42 and succeeded; its response had nowhere to go. The model's own retry at 02:16:53 then ran it again. The 404 on the first POST was before anything was forwarded, so the client-side retry itself was safe — it is the reconnect closing the session carrying it that makes the outcome unknowable.

Suggested addition to "What Should Happen": a reconnect must not close a transport that has a tools/call in flight, and a call lost after it was sent should be reported as "outcome unknown", not "session expired", since the latter reads as "nothing happened, retry".

richardpowellus · 13 days ago

Another consequence, reproduced on 2.1.286: the retried tools/call is executed, but the reconnect then closes the connection it was sent on, so its answer is lost. The caller is told the call failed, and the model repeats a write that has already happened.

Debug log (stub HTTP server that records each execution; server restarted between two calls):

14:23:48.033Z Connection error: Error POSTing to endpoint: unknown session
14:23:48.033Z MCP session expired (server no longer recognizes session ID), triggering reconnection
14:23:48.035Z http transport closed — reconnecting (attempt 1/5)
14:23:48.037Z Tool 'record_write' failed after 0s: Connection closed
14:23:48.040Z Retrying tool 'record_write' after session recovery
14:23:48.059Z Calling MCP tool: record_write          <- server executes it here
14:23:49.057Z Reconnect attempt 2/5
14:23:49.058Z Tool 'record_write' failed after 0s: Connection closed

The tool result was MCP server "echo" session expired, while the server's log showed the second marker had been written. In real use, an OneDrive create_folder was reported as "session expired", and the model's retry then got nameAlreadyExists. A create_draft was saved even though the call was reported as failed.

We have worked around it on the server side by resuming the old session id instead of answering 404, so the recovery path never starts. The client-side fix is still needed: the retry should not run on a connection that the reconnect loop is about to close, or its result should be returned when it does.