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サーバーが30秒で沈黙する — 原因はデバッグ用print 1行、stdioの地雷と3つの直し方

0
Posted at

自作のMCPサーバーをClaudeに繋いだら、initializeが成功した直後にツールが1つも呼べず、30秒で沈黙しました。エラーメッセージは出ない。ログをいくら眺めても原因が分からず、半日を溶かしてようやく突き止めたのが「デバッグ用の print が1行残っていた」ことでした。

stdioトランスポートでは、stdoutそのものがJSON-RPCの通信路です。つまりprintは、ヘッダも書式も持たないただのテキスト行を、プロトコルの stream に混ぜ込みます。クライアントは「JSONのはずの行をJSONとしてパースできず」、接続を切ります。この記事では、私が踏んだ失敗設計と、以後二度と踏まないための直し方3つを完全形のコードで残します。

なぜ「手元のテストは全部通る」のに繋ぐと死ぬのか

単体テストは関数を直接呼ぶので、stdoutが通信路である事実が一切現れません。printは普通にコンソールに出て、pytestは全緑です。壊れるのは「クライアントがstdoutをJSON-RPCとして読む」瞬間、つまり繋いだ時だけ。ここが一番タチ悪くて、手元のテストがいくら通っても、配布物としては赤です。

失敗した設計: デバッグ用printを残したままstdioで起動した

まず私が書いた駄目な方をそのまま載せます。これは失敗パターンなのでコピペしないでください(直し方は次章で完全形を出します)。

# ❌ 失敗パターン: printが混ざったままstdioで起動する
from mcp.server.fastmcp import FastMCP

NOTES = [
    "週次レポートの書き方",
    "API設計のメモ",
    "RAGの評価指標",
]

mcp = FastMCP("memo-search")

@mcp.tool()
def search_notes(keyword: str) -> str:
    """メモをキーワードで検索して、該当メモのタイトル一覧を返す"""
    print(f"[debug] 検索キーワード: {keyword}")   # ← この1行でプロトコルが壊れる
    hits = [t for t in NOTES if keyword in t]
    print(f"[debug] ヒット数: {len(hits)}")        # ← これも同じ
    if not hits:
        return "該当なし"
    return "\n".join(f"- {t}" for t in hits)

if __name__ == "__main__":
    mcp.run(transport="stdio")

ツールを呼ぶたびに [debug] ... というJSONではない行がstdoutに流れ、クライアント側のパースが即座に失敗します。Claudeデスクトップなら「ツールの実行に失敗」、自作クライアントならタイムアウトか沈黙。しかもサーバー側には何も出ないので、ログを眺めるほど混乱が深まります。私が半日溶かしたのはこの構図です。

直し方3つ

# 直し方 仕組み 向くケース
1 ログをstderrに集約する loggingのstreamにsys.stderrを明示指定 自分のコードのprintを置き換える(基本形)
2 print自体をstderrに差し替える builtins.printを起動時に入れ替える 直せないサードパーティ製コードのprintごと吸収
3 デバッグ中はstdio以外で起動する sse / streamable-httpはstdoutを通信路に使わない printを気にせず値を眺めたい開発時

直し方1: loggingをstderrに集約する(基本形)

Pythonのloggingはデフォルトでstderrに出ますが、明示的に書くのは「このサーバーはstderrに落ちる」という意志をコードで読めるようにするためです。printを全部loggerに置き換えます。

# ✅ 直し方1: printをloggingに置き換え、streamはstderrに明示する
import logging
import sys
from mcp.server.fastmcp import FastMCP

logging.basicConfig(
    level=logging.DEBUG,           # 配布時は INFO に下げる
    stream=sys.stderr,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("memo-search")

NOTES = [
    "週次レポートの書き方",
    "API設計のメモ",
    "RAGの評価指標",
]

mcp = FastMCP("memo-search")

@mcp.tool()
def search_notes(keyword: str) -> str:
    """メモをキーワードで検索して、該当メモのタイトル一覧を返す"""
    logger.debug("検索キーワード: %s", keyword)
    hits = [t for t in NOTES if keyword in t]
    logger.debug("ヒット数: %d", len(hits))
    if not hits:
        return "該当なし"
    return "\n".join(f"- {t}" for t in hits)

if __name__ == "__main__":
    mcp.run(transport="stdio")

直し方2: printそのものをstderrに差し替える(ライブラリ対策)

「自分のコードは直せるが、pipで入れたライブラリの中のprintは直せない」——このケースはbuiltins.printを起動時に入れ替える解決が効きます。公式SDKはJSON-RPCの書き込みに sys.stdout(のbuffer)を直接使うので、printを差し替えても通信路には一切触りません。

# ✅ 直し方2: builtins.printをstderrに差し替える(以後のprintは全部stderrへ)
import builtins
import sys

_original_print = builtins.print

def _print_to_stderr(*args, **kwargs):
    kwargs["file"] = sys.stderr
    _original_print(*args, **kwargs)

builtins.print = _print_to_stderr

# この行より後にimportするモジュールのprintも、すべてstderrに行く
from mcp.server.fastmcp import FastMCP

NOTES = [
    "週次レポートの書き方",
    "API設計のメモ",
    "RAGの評価指標",
]

mcp = FastMCP("memo-search")

@mcp.tool()
def search_notes(keyword: str) -> str:
    """メモをキーワードで検索して、該当メモのタイトル一覧を返す"""
    print("[debug] 検索キーワード: " + keyword)   # もう壊れない(stderrに出る)
    hits = [t for t in NOTES if keyword in t]
    if not hits:
        return "該当なし"
    return "\n".join(f"- {t}" for t in hits)

if __name__ == "__main__":
    mcp.run(transport="stdio")

1点だけ注意です。sys.stdout = sys.stderr のような「stdoutの差し替え」は絶対にやらないでください。 公式SDKは実行時に sys.stdout を参照してJSON-RPCの書き込み先を決めるので、stdoutを差し替えると通信路そのものがstderrに変わります。printを置き換える(✅)のと、stdoutを置き換える(❌)のは全く別物です。ここを勘違いすると、原因特定に余計な時間を溶かします。

直し方3: デバッグ中はHTTPトランスポートで動かす

stdioの縛りは「stdoutが通信路」であることから来ています。開発中だけHTTP系トランスポートで起動すればstdoutは自由になるので、printをいくら書いても壊れません。配布時だけstdioに戻します。

# ✅ 直し方3: デバッグ中はHTTP系トランスポートで起動する
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("memo-search", host="127.0.0.1", port=8000)

if __name__ == "__main__":
    # SDKのバージョンによっては "sse" の代わりに "streamable-http" を指定する
    mcp.run(transport="sse")

動作確認: クライアントから1回呼ぶ(これをパスすればstdoutはクリーン)

「テストが通るから大丈夫」は当てになりません。stdioの実害は、実際のクライアント経由でしか再現しません。以下は公式SDK(pip install mcp)だけで書ける検証クライアントです。上の直し方1〜3のサーバーを server.py という名前で保存した前提です。

# ✅ 検証クライアント: initialize → ツール呼び出しを1往復させる
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def main() -> None:
    params = StdioServerParameters(
        command="python",
        args=["server.py"],
    )
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            print("ツール一覧:", [t.name for t in tools.tools])
            result = await session.call_tool("search_notes", {"keyword": "レポート"})
            print("実行結果:", result.content[0].text)

asyncio.run(main())

このクライアント側のprintは無害です。クライアント自身はstdioトランスポートの上に立っていないからです(クライアントのstdoutはただのコンソール)。ツール呼び出しが1回でも戻れば、サーバーのstdoutはクリーンだと確認できます。試しに冒頭の失敗パターンに同じクライアントを繋ぐと、私の環境では call_tool が戻ってこず30秒で沈黙しました。事故の1行再現です。

症状ごとの切り分け表

症状 実際の原因になりやすいもの 確認方法
initialize直後に接続が切れる サーバー起動時に出るprint(バナー・設定値のダンプ) python server.py を単独で起動してstdoutに変な行が出ないか見る
ツール実行のときだけ失敗する ツール関数の中のprint 失敗するタイミングとstderrのログを突き合わせる
手元のテストは全部通るのに繋がらない 関数単位のテストはstdioを経由しない 必ずこの記事の検証クライアントで1往復させる

まとめ

  • stdioではstdoutがJSON-RPCそのもの。printはプロトコル破壊。テストでは絶対に露見しない
  • ログはlogging + stream=sys.stderr に集約する(直し方1)
  • 直せないprintはbuiltins.printの差し替えでまとめてstderrへ。sys.stdout の差し替えは通信路ごと壊すので禁止(直し方2)
  • 開発中はHTTP系トランスポート、配布時だけstdioに切り替える(直し方3)
  • 検証は実際のクライアントで1往復。これを通らないサーバーは配布できない

print1行に半日を溶かしたのは、良い勉強代だったとは言い難いです。ただ「stdioのstdoutは自分のものではない」と一度体で覚えると、二度と踏まない地雷になります。MCPサーバーを配布する前には、ぜひ一度stderrとstdoutを仕分けてください。

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

参考書籍: MCP実践入門 — AIエージェントを拡張する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?