0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MCPサーバーは夜間に静かに死ぬ — stdio再接続の完全実装で24時間エージェントを止めなくした3つの修正

0
Posted at

2026年10月上旬の話です。私はMCPサーバー経由で日誌DBを更新する夜間エージェントを運用していました。23時の起動は正常、ところが午前2時57分にサーバープロセスが静かに死んでエージェントが沈黙しました。ログはBrokenPipeErrorの1行だけで、原因が分からないまま2日を溶かし、朝までに4本のジョブが全滅しました。原因は「MCPサーバーは死なない」という前提で組んだstdio接続でした。この記事では、死因を残す方法と、指数バックオフで自動再接続するクライアントの完全実装(標準ライブラリだけ・コピペで動く)を載せます。

何が起きていたか: 前半は走るのに後半で全滅する

まず実測のタイムラインを表にまとめました。

時刻 状態 ログに残ったもの
23:04 初回起動、initialize成功 起動ログのみ
02:57 サーバープロセス死亡(rc=-9) 死因はstderr側。しかし捨てていたので不明
02:58 ジョブ1がBrokenPipeErrorで終了 traceback 1行、再試行なし
03:40 ジョブ2〜4も同じ箇所で沈黙 リカバリは朝の手動作業になった

MCPのstdioトランスポートは「クライアントがサーバーを子プロセスとして立て、標準入出力でJSON-RPCを流す」構成です。子プロセスは自分より長く生きる保証がどこにもなく、メモリ枯渇でのkillやライブラリの未処理例外で夜間に死にます。死ぬこと自体は防げなくても、検知と復旧はこちらで設計する必要がありました。

失敗した設計: 再接続なし・stderr破棄のstdioクライアント

最初に書いた駄目なコードがこちらです。これは失敗パターンなのでそのままコピペしないでください(正しい形は次章に完全形があります)。

# ❌ 失敗設計: 再接続なし・stderr破棄・死因調査不能のstdioクライアント
import json
import subprocess

proc = subprocess.Popen(["python3", "mcp_server.py"],
                        stdin=subprocess.PIPE, stdout=subprocess.PIPE,
                        stderr=subprocess.DEVNULL,   # ← 死因がここを流れるのに捨てている
                        text=True, encoding="utf-8", bufsize=1)

def call_tool(name, arguments):
    proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": 1,
                                 "method": "tools/call",
                                 "params": {"name": name, "arguments": arguments}}) + "\n")
    proc.stdin.flush()
    resp = json.loads(proc.stdout.readline())   # ← プロセス死亡でEOFになり、ここで落ちる
    return resp.get("result")

print(call_tool("search", {"query": "日誌"}))   # 数時間は順調に動く
print(call_tool("search", {"query": "月次"}))   # ← サーバー死亡後、ここでBrokenPipeError

このコードの決定的な失敗は次の3点です。

  1. 再接続の発想が無い — 子プロセスは1度立てたら永久に生きる前提。死んだら例外が出るだけで、起動し直して同じ要求をやり直す層が無いためジョブごと落ちる
  2. stderrをDEVNULLで破棄 — 事故の死因(out of memory・未処理例外など)はstderrに流れる。捨てた結果、2日間の原因究明は完全に暗号解読になった
  3. stdoutのEOFを無加工でreadline — プロセス死亡後は空文字を返すので、json.loadsが落ちて本命のエラーメッセージすら残らない

直し方1: 再接続つきstdioクライアント(標準ライブラリだけの完全形)

要求のやり直し・プロセス再起動・initializeのやり直し・stderrの保存・指数バックオフを、1ファイルに全部入れました。末尾の__main__には「2回目のtools/callで自爆するデモサーバー」まで同梱してあるので、そのまま実行すれば再接続の動作を観察できます。

#!/usr/bin/env python3
# ✅ 完全形: 再接続つきMCP stdioクライアント(標準ライブラリのみ・コピペで動く)
import collections
import json
import queue
import random
import subprocess
import threading
import time


class McpStdioClient:
    """stdioトランスポートのMCPクライアント。
    サーバープロセスの死亡と書き込み断線を検知したら、指数バックオフで
    再起動 → initialize → 同じ要求のやり直しまで自動で行う。"""

    def __init__(self, command, max_retries=5, base_delay=1.0):
        self.command = list(command)   # 例: ["python3", "/tmp/mcp_demo_flaky_server.py"]
        self.max_retries = max_retries
        self.base_delay = base_delay
        self.proc = None
        self.reads = queue.Queue()     # 現世代のサーバー → クライアント行
        self.err_buf = collections.deque(maxlen=20)   # 死因調査用のstderr末尾
        self.threads = []
        self.next_id = 0

    def start(self):
        """プロセス起動+initializeまで。死んだプロセスからの復帰でも同じ関数を使う。"""
        self.proc = subprocess.Popen(
            self.command,
            stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
            text=True, encoding="utf-8", bufsize=1,
        )
        self.reads = queue.Queue()          # 世代が変わるので積み直す
        self.err_buf.clear()
        self.threads = [
            threading.Thread(target=self._pump, args=(self.proc.stdout, self.reads), daemon=True),
            threading.Thread(target=self._pump, args=(self.proc.stderr, self.err_buf), daemon=True),
        ]
        for th in self.threads:
            th.start()
        self.call("initialize", {
            "protocolVersion": "2024-11-05",
            "capabilities": {},
            "clientInfo": {"name": "retry-client", "version": "1.0"},
        }, timeout=15)

    def _pump(self, pipe, sink):
        """パイプを1行ずつ拾ってsinkへ。プロセス死亡時はEOF/例外で自然に終わる。"""
        try:
            for line in pipe:
                text = line.strip()
                if not text:
                    continue
            # 出口がQueueなら応答流、dequeならstderr保存と使い分ける
                if isinstance(sink, queue.Queue):
                    sink.put(text)
                else:
                    sink.append(text)
        except (ValueError, OSError):
            pass
        if isinstance(sink, queue.Queue):
            sink.put(None)              # 終了合図

    def _send(self, obj):
        if self.proc is None or self.proc.poll() is not None:
            raise ConnectionError(f"サーバープロセス停止 rc={self._exit_code()}")
        try:
            self.proc.stdin.write(json.dumps(obj, ensure_ascii=False) + "\n")
            self.proc.stdin.flush()
        except (BrokenPipeError, OSError) as e:
            raise ConnectionError(f"書き込み失敗: {e}") from e

    def _exit_code(self):
        code = self.proc.returncode if self.proc else None
        return code if code is not None else "生存中"

    def _wait_response(self, want_id, timeout):
        deadline = time.monotonic() + timeout
        while True:
            remain = deadline - time.monotonic()
            if remain <= 0:
                raise TimeoutError(f"{timeout}秒以内にid={want_id}の応答なし")
            try:
                line = self.reads.get(timeout=remain)
            except queue.Empty:
                continue
            if line is None:
                raise ConnectionError("サーバーの標準出力が閉じました")
            if not line:
                continue
            try:
                msg = json.loads(line)
            except json.JSONDecodeError:
                continue          # 雑音行は無視する
            if msg.get("id") != want_id:
                continue          # 通知とサーバー起点の要求はここでは扱わない
            if "error" in msg:
                raise RuntimeError(f"MCPエラー: {msg['error']}")
            return msg.get("result", {})

    def call(self, method, params, timeout=60):
        self.next_id += 1
        req_id = self.next_id
        self._send({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params})
        return self._wait_response(req_id, timeout)

    def call_tool(self, name, arguments, timeout=60):
        result = self.call("tools/call", {"name": name, "arguments": arguments}, timeout=timeout)
        return "".join(c.get("text", "") for c in result.get("content", []))

    def call_tool_safe(self, name, arguments, timeout=60):
        """本丸。切断とタイムアウトを指数バックオフで自動復旧する。
        MCPエラー応答(RuntimeError)は再試行しない: 何度やっても同じ失敗だから。"""
        last_error = None
        for attempt in range(1, self.max_retries + 1):
            try:
                if self.proc is None or self.proc.poll() is not None:
                    self.start()   # 未接続・切断済みなら初期化から
                return self.call_tool(name, arguments, timeout=timeout)
            except (ConnectionError, TimeoutError) as e:
                last_error = e
                if self.err_buf:
                    print(f"[retry-client] stderr末尾: {list(self.err_buf)[-3:]}")
                delay = self.base_delay * (2 ** (attempt - 1)) + random.uniform(0, 0.5)
                print(f"[retry-client] 試行{attempt}失敗: {e} → {delay:.1f}秒待って再接続")
                self.shutdown()
                time.sleep(delay)
        raise ConnectionError(f"{self.max_retries}回の再接続でも通じません: {last_error}")

    def shutdown(self):
        proc, self.proc = self.proc, None
        if proc is None:
            return
        try:
            proc.terminate()
            proc.wait(timeout=5)
        except Exception:
            pass
        try:
            proc.kill()
        except Exception:
            pass


# デモ用: 2回目のtools/callで自爆するMCPサーバー(夜間の突然死を再現する)
SERVER_SRC = '''import json
import sys
import os

STATE = {"count": 0}
for line in sys.stdin:
    line = line.strip()
    if not line:
        continue
    msg = json.loads(line)
    if "id" not in msg:
        continue
    method = msg.get("method", "")
    if method == "initialize":
        result = {"protocolVersion": "2024-11-05",
                  "capabilities": {"tools": {}},
                  "serverInfo": {"name": "echo", "version": "1.0"}}
    elif method == "tools/call":
        STATE["count"] += 1
        if STATE["count"] >= 2:   # 同一プロセスの2回目で自爆(夜間の突然死の再現)
            print("FATAL: デモ用に自爆します", file=sys.stderr, flush=True)
            os._exit(137)
        args_text = json.dumps(msg["params"].get("arguments", {}), ensure_ascii=False)
        result = {"content": [{"type": "text", "text": "echo: " + args_text}]}
    else:
        result = {}
    sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": result},
                                ensure_ascii=False) + "\\n")
    sys.stdout.flush()
'''


if __name__ == "__main__":
    import os
    import sys
    import tempfile

    server_path = os.path.join(tempfile.gettempdir(), "mcp_demo_flaky_server.py")
    with open(server_path, "w", encoding="utf-8") as f:
        f.write(SERVER_SRC)

    client = McpStdioClient([sys.executable, server_path], max_retries=4, base_delay=0.5)
    print("1回目:", client.call_tool_safe("echo", {"message": "初回"}))
    print("2回目:", client.call_tool_safe("echo", {"message": "自爆後の自動再接続"}))
    print("3回目:", client.call_tool_safe("echo", {"message": "再接続後も平気"}))
    client.shutdown()

実行すると、2回目と3回目の要求でFATALをstderrから拾い、バックオフして再接続し、同じ要求を失敗ゼロでやり直す様子がそのまま見えます。ポイントは次の3つです。

  1. stderrは捨てない — dequeに末尾20行だけ残し、異常時に原因の第一手がかりにする
  2. MCPエラー応答は再試行しない — RuntimeErrorはリトライ対象外。切断とタイムアウトだけを復旧対象にする(何度やっても同じ失敗を待つのを避ける)
  3. 世代ごとのqueue — 再起動のたびに応答キューを積み直し、前のプロセスの残骸が後の応答に混入しないようにする

直し方2: 長時間ジョブはStreamable HTTPに寄せる

そもそも論として、夜間バッチのように「子プロセスと数時間共存する」用途はstdioと相性が悪いです。MCPのStreamable HTTPトランスポートなら、サーバーは常駐し、クライアントは1リクエストごとに接続するので、寿命の共有が消えます。残る失敗は「セッションIDの喪失」だけなので、喪失時に初期化からやり直す形にすれば回復します。標準ライブラリだけで書いたのがこちらです。

#!/usr/bin/env python3
# ✅ 完全形: Streamable HTTPの夜間バッチ(セッション喪失を自動回復・標準ライブラリのみ)
import json
import random
import time
import urllib.error
import urllib.request

MCP_URL = "http://localhost:8080/mcp"    # FastMCP(mcp run)のデフォルト等のエンドポイント
session_id = None                        # サーバーから払い出されたセッションID


def _headers():
    h = {
        "Content-Type": "application/json",
        "Accept": "application/json, text/event-stream",
    }
    if session_id:
        h["Mcp-Session-Id"] = session_id
    return h


def _post_json(payload, timeout):
    global session_id
    req = urllib.request.Request(MCP_URL, data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
                                 headers=_headers(), method="POST")
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        sid = resp.headers.get("Mcp-Session-Id")
        if sid:
            session_id = sid             # 払い出されたら以後の要求に付ける
        return resp.read().decode("utf-8", errors="replace")


def _extract_result(payload_text, want_id):
    """応答は素JSONかSSE(data:行)。id一致のレスポンスだけを抜き出す。"""
    for line in payload_text.splitlines():
        line = line.strip()
        if line.startswith("data:"):
            line = line[5:].strip()
        if not line or not line.startswith("{"):
            continue
        msg = json.loads(line)
        if msg.get("id") == want_id and "result" in msg:
            return msg["result"]
    raise TimeoutError("応答の中に一致するidがありませんでした")


def notify(method, params):
    """応答を待たない通知。initializedの送付などに使う。"""
    try:
        _post_json({"jsonrpc": "2.0", "method": method, "params": params}, timeout=15)
    except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as e:
        print(f"[http] 通知失敗(継続): {e}")


def initialize():
    """セッションを新規に張る。古いIDは外して送らない。"""
    global session_id
    session_id = None
    rpc("initialize", {
        "protocolVersion": "2024-11-05",
        "capabilities": {},
        "clientInfo": {"name": "batch", "version": "1.0"},
    })
    notify("notifications/initialized", {})


def rpc(method, params, timeout=30, retries=4):
    """1要求を送る。セッション喪失(400/404)は初期化から回復、レート系は指数バックオフ。"""
    delay = 1.0
    for attempt in range(1, retries + 1):
        try:
            text = _post_json({"jsonrpc": "2.0", "id": attempt, "method": method, "params": params}, timeout)
            return _extract_result(text, attempt)
        except urllib.error.HTTPError as e:
            if e.code in (400, 404) and method != "initialize":
                print(f"[http] {e.code} → セッション喪失の疑い → 初期化からやり直し")
                initialize()
                continue
            if e.code in (429, 503) and attempt < retries:
                print(f"[http] {e.code} → {delay:.1f}秒待って再試行")
                time.sleep(delay)
                delay = min(delay * 2 + random.uniform(0, 0.3), 15.0)
                continue
            raise
        except (urllib.error.URLError, TimeoutError) as e:
            if attempt < retries:
                print(f"[http] 通信断({type(e).__name__}) → {delay:.1f}秒待って再試行")
                time.sleep(delay)
                delay = min(delay * 2 + random.uniform(0, 0.3), 15.0)
                continue
            raise
    raise RuntimeError(f"{retries}回の再試行でも通りませんでした: {method}")


if __name__ == "__main__":
    initialize()
    print(json.dumps(rpc("tools/call", {"name": "search", "arguments": {"query": "日誌 2026-10"}}),
                     ensure_ascii=False, indent=2))

404が返ってきたら「セッションが無効」と判断してsession_idを捨て、initializeからやり直します。サーバーの再起動・セッションの失効・デプロイ直後の接続切れがどれでも吸収できます。

2つのトランスポートの使い分け

観点 stdio Streamable HTTP
寿命 子プロセスと共存。プロセスが死んだら一緒に止まる サーバーは常駐。クライアントの寿命と独立
復旧 プロセス再起動+initializeのやり直しが必要 セッションIDを払い直せば回復
に向く 手元ツール・数秒〜数分で終わるスクリプト 夜間バッチ・長時間の複数ジョブ
実装コスト 標準ライブラリだけで完結できる HTTPクライアント1個だが、セッション管理の理解が要る

私の判断基準は「ジョブの寿命をプロセスと共有するか」です。共有するなら再接続層を必ず入れるか、HTTPに寄せるかの2択になります。

まとめ

  • 子プロセスは夜間に静かに死ぬ前提で設計する。stderrは必ず保存し、死因を残す(直し方1のerr_buf)
  • 再接続は「プロセス再起動→initialize→同じ要求のやり直し」まで1本の関数にまとめる。MCPエラー応答(RuntimeError)は再試行しない理由とセットで設計する
  • 長時間ジョブはStreamable HTTPに寄せる。寿命の共有が消え、残る失敗はセッション喪失だけになり、喪失時は初期化からやり直せば回復する

直し方1のクライアントはコピペで動き、デモサーバーの自爆も再現します。夜間にエージェントが沈黙して朝に慌てる日々を、今日で終わりにしてください。

🎁 読者特典を受け取る(無料・メール登録)

参考書籍: Model Context Protocol実践入門 — エージェントとツールをつなぐ設計の教科書(Kindle・Kindle Unlimited読み放題対象)(葉山悠希 著)

著者: 葉山悠希 — 書籍シリーズは Zenn / Amazon で公開中

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?