CLAUDE LABJP
MCP — Support for the 2026-07-28 spec is rolling out across Claude. The protocol moves from bidirectional and stateful to request/response, so MCP servers can now live on serverless and edge infrastructureEXTENSIONS — Three official extensions have landed: MCP Apps for server-rendered UI, Tasks for async and long-running work, and Enterprise Managed Auth for IdP-based org-wide provisioningADOPTION — MCP passed 400 million monthly SDK downloads, roughly 4x growth this year, settling into its role as the standard way to connect agents to applicationsQUOTA — Today, August 19, is the last day of the 50 percent weekly usage boost for Claude Code subscribers. If you have long agent runs queued, this is the windowPRICING — Claude Sonnet 5's introductory rate of $2 per million input tokens and $10 output ends August 31; standard pricing of $3 and $15 takes over on September 1, twelve days outFIX — A bug where MCP v2 connections endlessly reopened subscriptions against servers with fixed timeouts is resolved, and a forward_user_identity setting was added for user attributionMCP — Support for the 2026-07-28 spec is rolling out across Claude. The protocol moves from bidirectional and stateful to request/response, so MCP servers can now live on serverless and edge infrastructureEXTENSIONS — Three official extensions have landed: MCP Apps for server-rendered UI, Tasks for async and long-running work, and Enterprise Managed Auth for IdP-based org-wide provisioningADOPTION — MCP passed 400 million monthly SDK downloads, roughly 4x growth this year, settling into its role as the standard way to connect agents to applicationsQUOTA — Today, August 19, is the last day of the 50 percent weekly usage boost for Claude Code subscribers. If you have long agent runs queued, this is the windowPRICING — Claude Sonnet 5's introductory rate of $2 per million input tokens and $10 output ends August 31; standard pricing of $3 and $15 takes over on September 1, twelve days outFIX — A bug where MCP v2 connections endlessly reopened subscriptions against servers with fixed timeouts is resolved, and a forward_user_identity setting was added for user attribution
Articles/API & SDK
API & SDK/2026-08-19Intermediate

The listen Stream Kept Reopening Against My Serverless MCP Server

No errors anywhere, yet connections to the MCP server kept piling up overnight. Here is what happens when a fixed platform timeout meets a resubscribe loop, reproduced in a few dozen lines.

MCP51Claude Code225ServerlessSSE6Troubleshooting13

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:

Second12345678
Stale subscription abandoned01234556
Closed before reopening01101011

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 state

Then 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 -30

A flat count per minute means resubscription is behaving. A straight climb means old subscriptions are never being closed.

What you observeWhere to lookNext move
Concurrency climbs steadily over hoursClient resubscribe logic, or stale socket cleanupUpdate the CLI and re-measure over the same window
Still climbing after the updateServer-side connection managementAdd per-session dedup and a cap (next section)
Count is flat but reconnects are frequentThe platform's fixed timeoutNot a defect. Review backoff and redelivery instead
Only tool calls fail, listen is fineRequest timeouts, not the streamCheck 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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

API & SDK2026-08-15
The Connection That Dies Mid-Thought Is Being Killed by Your Relay, Not the Upstream
Put a proxy or Worker in front of Claude Code and long thinking pauses start dying. The cause is neither the model nor the upstream — it is your relay holding the byte stream. Six relay behaviors compared side by side, plus how to verify yours before it ships.
API & SDK2026-07-26
Rebuilding a Remote MCP Server That Never Needed Mcp-Session-Id
The MCP spec release candidate drops the session header. Here is how I audited my own remote server for session coupling and moved it to signed cursors, with measurements.
Claude Code2026-05-24
Recovering from Claude Code's 'Tool result could not be submitted'
What 'Tool result could not be submitted' really means in Claude Code, and the practical recovery steps I rely on after years of running indie apps with 50M+ downloads through it.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →