朝いちばんにアクセスログを開いて、まず数字の桁を疑いました。前の晩に自分がつないだ MCP サーバーへのリクエストが、想定していた回数の何十倍にもなっていたからです。
エラーは一件も出ていません。ツールの呼び出しは全部成功しています。増えていたのは /subscriptions/listen — 通知を受け取るために張りっぱなしにしておく、あの一本だけでした。
個人開発で運用しているサイト群は Cloudflare Workers の上にあって、長時間の接続が一定時間で打ち切られる性質とは以前から付き合ってきました。それでも、切られること自体が問題になるとは考えていませんでした。実際に困るのは、切られたあとの振る舞いのほうです。
切断エラーは出ないのに、接続だけが増えていく
この症状の厄介なところは、どの層を見ても「失敗」として記録されないことです。
サーバーレスの実行環境には、ひとつのリクエストを保持できる時間に上限があります。Cloudflare Workers、Lambda の Function URL、Cloud Run のどれでも、値は違えど上限はあります。長時間つなぎっぱなしにする listen ストリームは、その上限に必ずぶつかります。ぶつかったとき、プラットフォームは 500 を返すのではなく、静かにストリームを終わらせます。
クライアント側から見れば、これは「正常に終わった読み取り」か、あるいは「無言のまま何も届かない状態」です。前者なら再購読は自然な振る舞いですし、後者でも一定時間で見切りをつけて張り直すのが妥当です。どちらの判断も、単体では間違っていません。
問題は、その張り直しが古い購読を畳まないまま行われたときに起きます。サーバー側の同時接続数だけが単調に増えていき、しかもどこにも例外が記録されません。私自身、最初はリクエスト数の集計ミスを疑って、そちらを先に調べてしまいました。
Claude Code 側でも、この噛み合わせは実際に不具合として扱われています。リリースノートには「固定タイムアウトで長時間ストリームを打ち切るサーバー(サーバーレスホストなど)に対して、MCP v2 の接続が subscriptions/listen ストリームを際限なく開き直す問題を修正」という趣旨の記述があります。つまり、自分のサーバーの設計が原因である場合と、クライアント側のバージョンが原因である場合の両方がありうるということです。
固定タイムアウトと再購読が噛み合うと何が起きるか
言葉で説明するより、動かしたほうが早いと考えました。実機の 30〜100 秒という時間軸をそのまま再現すると検証に何十分もかかるので、比率を保ったまま縮めた相似モデルを組んでいます。
サーバーは「受理して、しばらくしたら黙る。ただし FIN は返さない」という、いちばん質の悪い切られ方を再現します。クライアントは、無音が続いたら切れたとみなして張り直します。
# repro.py — 固定タイムアウトで黙るサーバーと、それに再購読するクライアント
import socket, threading, time, json
SILENT_AFTER = 1.0 # 実機の固定タイムアウトを縮めた相似値
open_conns = 0
lock = threading.Lock()
samples = []
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) # ここから無言。FIN は送らない
while not stop.is_set(): # クライアントが閉じるまで保持し続ける
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) # 無音が続いたら切れたとみなす
socks.append(c)
while True:
if not c.recv(1024):
break
except socket.timeout:
if close_old: # 修正版: 古い購読を畳んでから張り直す
for s0 in socks:
s0.close()
socks = []
time.sleep(0.5) # バックオフ
# close_old=False は古い接続を放置したまま次を張る
except Exception:
time.sleep(0.2)サーバー側で毎秒サンプリングした同時接続数は、次のようになりました。
| 経過秒 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| 古い購読を放置 | 0 | 1 | 2 | 3 | 4 | 5 | 5 | 6 |
| 畳んでから張り直す | 0 | 1 | 1 | 0 | 1 | 0 | 1 | 1 |
放置した側は、タイムアウトの周期ごとにおおむね1本ずつ積み上がっていきます。畳んだ側は 0 か 1 を行き来するだけで、上限を持ちません。この差はサンドボックス上の相似モデルで得た値なので、実機の絶対値としては読まないでください。読み取るべきは形のほうです。片方は時間に比例して増え、もう片方は増えない。
実機の固定タイムアウトが仮に 60 秒なら、増えるほうは 1 時間で 60 本、朝までに数百本になります。無料枠の同時接続数や、サーバーレスの課金単位である実行時間に、この本数がそのまま効いてきます。
そして繰り返しになりますが、この間に例外は一度も飛びません。私がリクエスト数の桁を疑ったのは、疑うべき異常値がそこにしか現れていなかったからです。
自分の環境がどちらなのかを切り分ける
原因はクライアント側かサーバー側か、あるいは両方か。順番を決めておくと迷いません。
まず CLI のバージョンを見ます。前述の修正が入ったあとのバージョンであれば、クライアント起因の可能性はひとまず下がります。
claude --version
claude mcp list # 接続先と現在の状態を確認次に、サーバー側で「同時に開いている listen ストリームの本数」を数えます。リクエスト総数ではなく、同時本数であることが肝心です。総数は正常な再接続でも増えるので、異常の判定には使えません。
# 自前サーバーのアクセスログから listen だけを抜き、分ごとの本数を数える
awk '$7 ~ /subscriptions\/listen/ {print substr($4, 2, 17)}' access.log \
| sort | uniq -c | tail -30分ごとの本数が横ばいなら、再購読は正常に回っています。単調に増えていれば、古い購読が畳まれていません。
| 観測されたこと | 疑う場所 | 次の一手 |
|---|---|---|
| 同時本数が時間とともに増え続ける | クライアントの再購読、または古い接続の後始末 | CLI を更新して同じ時間帯で再計測する |
| CLI 更新後も増え続ける | サーバー側の接続管理 | 購読の重複排除と上限を入れる(次節) |
| 本数は横ばいだが再接続が頻繁 | プラットフォームの固定タイムアウト | 異常ではない。バックオフと再送設計を確認する |
| ツール呼び出し側だけが失敗する | listen ではなくリクエストのタイムアウト | タイムアウト設定の実効値を確認する |
最後の行は別の症状です。設定したはずのタイムアウトが効かず一定時間で切れる話は、サーバー単位の request_timeout_ms を取り戻す記事のほうで扱っています。突然つながらなくなる系統の切り分けは、5ステップ診断のほうが近道です。
サーバー側で「張り直しても増えない」形にする
クライアントの更新で直る場合もありますが、サーバー側を直しておくと、どのクライアントが来ても壊れません。個人で運用するなら、こちらのほうが後々らくだと私は考えています。
要点は3つあります。
1. 購読はセッション単位で1本に制限する。 同じセッションから2本目の listen が来たら、古いほうを閉じてから新しいほうを受けます。これだけで単調増加は止まります。
// Cloudflare Workers 想定の擬似コード
const streams = new Map<string, WritableStreamDefaultWriter>();
async function onListen(sessionId: string, writer: WritableStreamDefaultWriter) {
const prev = streams.get(sessionId);
if (prev) {
// 同一セッションの古い購読は、新しいものを受ける前に必ず閉じる
try { await prev.close(); } catch { /* 既に閉じている場合は無視 */ }
}
streams.set(sessionId, writer);
}2. 切られる時刻を、こちらから先に伝える。 プラットフォームに打ち切られるのを待たず、上限より少し手前で自分から終端します。あわせて SSE の retry フィールドで、次に張り直すまでの待ち時間を指定します。クライアントが即時再接続するかどうかを、サーバー側から誘導できます。
const MAX_HOLD_MS = 50_000; // プラットフォーム上限より手前で自分から閉じる
const RETRY_MS = 3_000; // 次の接続までクライアントに待ってもらう
await writer.write(encoder.encode(`retry: ${RETRY_MS}\n\n`));
setTimeout(() => writer.close(), MAX_HOLD_MS);自分で閉じると、クライアントには「正常な終端」として届きます。無言のまま放置されるより、はるかに扱いやすい形です。
3. 無音を作らない。 15 秒程度の間隔でコメント行だけを送っておくと、中継のプロキシに idle と判断されて切られる事故が減ります。SSE のコメント行は : で始まり、クライアントからはイベントとして見えません。
setInterval(() => writer.write(encoder.encode(": keepalive\n\n")), 15_000);この3つを入れたあと、私は同じ再現コードをもう一度回しました。サーバー側から終端して retry を返す構成では、クライアントが古い購読を畳まない実装であっても、同時本数は上限に張り付かず一定の範囲に収まります。クライアントの行儀のよさに依存しない形にできたのが、いちばんの収穫でした。
なお、そもそも接続に状態を持たせない設計に寄せるという方向もあります。そちらは状態の置き場所を作り直す記事で扱っています。
最初にやる一手
自前の MCP サーバーを持っているなら、今日のうちに同時接続本数の時系列をひとつ取ってみてください。総数ではなく同時本数です。横ばいなら何もする必要はありませんし、右肩上がりの直線が見えたら、それが探していた異常そのものです。
私の場合、この一本のグラフを見るまでに二晩ぶんの請求を余計に払いました。先に見ておけば済んだ話です。
無人で動かす定期実行に MCP を組み込む場合の設計や、失敗を検知する仕組みについては、メンバーシップ向けの記事でもう少し踏み込んで扱っています。実装の参考になれば幸いです。お読みいただきありがとうございました。