自作の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読み放題対象)(葉山悠希 著)