The first thing I did when I opened the access log that morning was double-check the digit count. Requests to the MCP server I had connected the night before were dozens of times higher than anything I expected.
Not a single error. Every tool call had succeeded. The only thing multiplying was /subscriptions/listen — the one connection you hold open so notifications can arrive.
The sites I run as an indie developer live on Cloudflare Workers, so I had made peace long ago with the fact that long-lived connections get cut after a while. What I had not thought through was that getting cut is not the problem. What happens after the cut is.
No disconnect errors, just a growing connection count
What makes this symptom awkward is that no layer records it as a failure.
Serverless runtimes cap how long a single request may be held open. Cloudflare Workers, Lambda Function URLs, Cloud Run — the numbers differ, but the ceiling exists everywhere. A listen stream you intend to keep open indefinitely will hit that ceiling every time. When it does, the platform does not return a 500. It quietly ends the stream.
From the client's side, that looks either like a read that finished normally, or like a stream that has simply gone silent. Resubscribing after a clean end is reasonable. Giving up after a period of silence and reconnecting is also reasonable. Neither decision is wrong on its own.
The trouble starts when the reconnect happens without closing the previous subscription. Concurrent connections on the server climb steadily, and no exception is logged anywhere along the way. My first instinct was to suspect my own request-counting query, so that is where I wasted the first half hour.
Claude Code treats this pairing as a real defect, too. Its release notes describe a fix for MCP v2 connections endlessly reopening the subscriptions/listen stream against servers that terminate long-held streams on a fixed timeout — serverless hosts being the obvious example. Which means the cause can live in your server design, in your client version, or in both at once.
What a fixed timeout plus a resubscribe loop actually does
Rather than reason about it, I built the smallest thing that would show it. Real-world timeouts sit somewhere around 30 to 100 seconds, and waiting that out repeatedly makes for a slow afternoon, so the model below keeps the shape and shrinks the clock.
The server reproduces the least helpful way a stream can end: accept the connection, go quiet after a while, and never send a FIN. The client decides the stream is dead once the silence runs long enough, and opens a new one.
# repro.py — a server that goes silent on a fixed timeout, and a client that resubscribes
import socket, threading, time
SILENT_AFTER = 1.0 # scaled-down stand-in for the real fixed timeout
open_conns = 0
lock = threading.Lock()
def server(port, stop):
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", port)); s.listen(128); s.settimeout(0.2)
def handle(c):
global open_conns
with lock:
open_conns += 1
try:
c.recv(1024)
c.sendall(b"HTTP/1.1 200 OK\r\n"
b"Content-Type: text/event-stream\r\n\r\n: open\n\n")
time.sleep(SILENT_AFTER) # goes quiet here, sends no FIN
while not stop.is_set(): # holds the socket until the client closes it
try:
c.settimeout(0.2)
if c.recv(1) == b"":
break
except socket.timeout:
continue
except Exception:
break
finally:
with lock:
open_conns -= 1
c.close()
while not stop.is_set():
try:
c, _ = s.accept()
except socket.timeout:
continue
threading.Thread(target=handle, args=(c,), daemon=True).start()
s.close()
def client(port, stop, close_old):
socks = []
while not stop.is_set():
try:
c = socket.create_connection(("127.0.0.1", port), timeout=1)
c.sendall(b"GET /subscriptions/listen HTTP/1.1\r\nHost: x\r\n\r\n")
c.settimeout(1.2) # treat prolonged silence as a dead stream
socks.append(c)
while True:
if not c.recv(1024):
break
except socket.timeout:
if close_old: # fixed version: close the old subscription first
for s0 in socks:
s0.close()
socks = []
time.sleep(0.5) # back off before reopening
# with close_old=False, the stale socket is simply abandoned
except Exception:
time.sleep(0.2)Sampling concurrent connections on the server once per second produced this:
| Second | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Stale subscription abandoned | 0 | 1 | 2 | 3 | 4 | 5 | 5 | 6 |
| Closed before reopening | 0 | 1 | 1 | 0 | 1 | 0 | 1 | 1 |
The abandoning version adds roughly one stream per timeout cycle. The closing version oscillates between zero and one and never accumulates. These are numbers from a scaled sandbox model, so do not read them as production figures. Read the shape instead: one line grows with time, the other does not.
If your real ceiling is 60 seconds, the growing line reaches 60 streams in an hour and several hundred by morning. That count lands directly on your concurrency limits and, on serverless, on the billed execution time.
And through all of it, nothing throws. I went looking at my counting query first because the anomaly had nowhere else to surface.
Deciding which side is at fault
Client or server, or both. Having a fixed order to check saves a lot of guessing.
Start with the CLI version. If you are past the release that carried the fix above, the client-side explanation drops down the list.
claude --version
claude mcp list # which servers are attached, and their current stateThen count the concurrent listen streams on the server. Not total requests — concurrency. Totals rise during perfectly healthy reconnect cycles, so they cannot distinguish the two cases.
# pull only the listen requests out of your own access log and count them per minute
awk '$7 ~ /subscriptions\/listen/ {print substr($4, 2, 17)}' access.log \
| sort | uniq -c | tail -30A flat count per minute means resubscription is behaving. A straight climb means old subscriptions are never being closed.
| What you observe | Where to look | Next move |
|---|---|---|
| Concurrency climbs steadily over hours | Client resubscribe logic, or stale socket cleanup | Update the CLI and re-measure over the same window |
| Still climbing after the update | Server-side connection management | Add per-session dedup and a cap (next section) |
| Count is flat but reconnects are frequent | The platform's fixed timeout | Not a defect. Review backoff and redelivery instead |
| Only tool calls fail, listen is fine | Request timeouts, not the stream | Check the effective timeout value in use |
That last row is a different animal. When a configured timeout silently fails to apply, the piece on reclaiming per-server request_timeout_ms is the closer match. For servers that stop responding altogether, the five-step disconnect walkthrough is faster.
Making the server safe to reopen against
Updating the client fixes some cases. Fixing the server fixes all of them, whichever client shows up. For a one-person operation, I find the second one pays for itself much sooner.
Three things matter.
1. Cap subscriptions to one per session. When a second listen arrives for the same session, close the first before accepting the second. That alone ends the monotonic growth.
// pseudo-code, written with Cloudflare Workers in mind
const streams = new Map<string, WritableStreamDefaultWriter>();
async function onListen(sessionId: string, writer: WritableStreamDefaultWriter) {
const prev = streams.get(sessionId);
if (prev) {
// always close the previous subscription before accepting the new one
try { await prev.close(); } catch { /* already closed — nothing to do */ }
}
streams.set(sessionId, writer);
}2. Announce the ending yourself. Do not wait for the platform to cut you off. Close a little before the ceiling, and use the SSE retry field to tell the client how long to wait before coming back. That turns "when does it reconnect" into something you control.
const MAX_HOLD_MS = 50_000; // close before the platform's own ceiling
const RETRY_MS = 3_000; // ask the client to wait before reconnecting
await writer.write(encoder.encode(`retry: ${RETRY_MS}\n\n`));
setTimeout(() => writer.close(), MAX_HOLD_MS);A stream you close yourself arrives at the client as a clean ending, which is far easier to handle than open-ended silence.
3. Never let the stream go quiet. A comment line every 15 seconds keeps intermediate proxies from deciding the connection is idle. SSE comment lines start with : and never surface as events on the client.
setInterval(() => writer.write(encoder.encode(": keepalive\n\n")), 15_000);With those three in place, I ran the same reproduction again. When the server ends the stream itself and returns a retry hint, concurrency stays within a bounded range even against a client that abandons its old subscriptions. Not depending on the client's good manners was the part I was really after.
There is also the option of not holding connection state at all. The piece on relocating that state covers that direction.
The one thing to do first
If you run your own MCP server, pull a single time series of concurrent listen streams today. Not totals. If it is flat, you are done. If a straight upward line appears, you have found the anomaly you were looking for.
In my case, two nights of extra billing went by before I plotted that one line. It would have taken five minutes to look sooner.
If you build MCP into unattended scheduled runs, the failure-detection side of that setup goes deeper than fits here, and the membership articles pick it up from there. Thank you for reading.