[BUG] HTTP MCP session recovery runs two initializes concurrently, leaking sessions (and failing the retried call on servers that limit sessions)
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:
- 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.
- 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 getsMCP server "swing" is not connectedfor 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.
3 Comments
The dual-path recovery race you logged looks real: transport reconnect and the tool-call wrapper both treat a 404-on-stale-
Mcp-Session-Idas “start initialize,” and nothing serializes them. Your probe timeline (twoinitializes within ~1 ms, then a third on reconnect attempt 2, never a DELETE) matches that shape, especially under--singlewhere each new session evicts the previous one.A few things that have helped isolate / soften this while waiting on a single shared recovery latch:
--debug-filearound one restart should show bothtriggering reconnection/reconnecting (attempt 1/5)andRetrying tool '…' after session recoverywithin a few ms of the same 404. If only one line appears, it is a different failure mode than the race.initializewhile one is in flight (or hold a 50–100 ms gate). If the client’s retriedtools/callthen reliably lands on the sole surviving session, that points at missing client-side coordination rather than a flaky transport.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.probe.py+restart.sh, server log with the threenew sessionlines and zero DELETE, plus the client debug excerpt with both recovery lines. Desired client behavior matches what you wrote: oneinitializeper 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.Another consequence of this race, seen on 2.1.286: the retried
tools/callreaches the server and completes, but the reconnect closes the connection it is waiting on, so the client reportsMCP 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):
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/callin 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".Another consequence, reproduced on 2.1.286: the retried
tools/callis 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):
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 OneDrivecreate_folderwas reported as "session expired", and the model's retry then gotnameAlreadyExists. Acreate_draftwas 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.