MCP サーバーを自作していて最初に詰まるのは、エラーメッセージではなく「何も起きない」状態ではないでしょうか。console.log("reached here") を仕込んでもターミナルに何も出てこありません。Claude Code 側では「ツールが応答しません」と表示されるだけで、どこが悪いのかまったく手がかりがありません。この無言の失敗は、ほぼ例外なく stdio 汚染 が原因です。
stdio トランスポートの MCP サーバーは、標準入出力そのものを JSON-RPC のチャネルとして使います。つまり、あなたの console.log が書き込んでいる「ログ」は、Claude Code から見ると「壊れた JSON レスポンス」になります。パーサはメッセージを破棄し、ハンドシェイクが失敗し、サーバーは接続されたまま静かに沈黙する—これが実際に起きていることです。
まず現象を確認する
ログを直す前に、自分のサーバーが本当に stdio 経由で動いているのか確認します。Claude Code の設定(~/.claude.json のプロジェクトセクション、または .mcp.json)を開いて、以下のような形になっていたら stdio モードです。
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["./mcp/server.js"]
}
}
}type: "http" や url が指定されていればネットワーク経由なので、この記事の内容は当てはまりません。stdio の場合のみ、stdout への書き込みがプロトコルと衝突します。
次に /mcp コマンドか claude mcp list でサーバーの状態を見ます。failed ではなく connected なのに反応しない、というのが典型的な症状です。サーバー側がクラッシュしたら failed と出るので、ここで connected のままなのは「プロセスは生きているが通信が成立していない」サインとして読んでください。
原因: stdout は MCP の通信路である
Node.js の console.log は内部的に process.stdout.write を呼びます。Python の print も同じで、デフォルトの送り先は stdout です。MCP の stdio トランスポートが使うのはまさにここで、1 行 1 メッセージの JSON-RPC をやりとりしています。
つまり、あなたが書いた console.log("user id:", userId) は、Claude Code のパーサから見ると次のようなノイズです。
user id: 42
{"jsonrpc":"2.0","result":{...},"id":1}
最初の行は JSON として不正なので、パーサはエラーを出すか、実装によってはその行以降をすべて捨てます。結果として、正しく返したはずのツール結果までまとめて失われます。ログを書いた「つもり」で、実際にはプロトコルを壊している—これが一番多いパターンです。
正しい書き換え: stderr に流す
標準エラー出力(stderr)は MCP プロトコルが使わないチャネルなので、ここに書く分にはいくらログを吐いても問題ありません。Claude Code のログビューアは、サーバープロセスの stderr をキャプチャして保存してくれます。
Node.js の場合は単純に console.error に置き換えます。
// ❌ サーバーを黙らせる
console.log("Tool called:", toolName);
// ✅ stderr に流す
console.error("Tool called:", toolName);
// ✅ もっと明示的に書きたい場合
process.stderr.write(`[mcp] tool=${toolName}\n`);Python の場合は sys.stderr に file 引数で流します。
import sys
# ❌ stdout を汚す
print(f"Tool called: {tool_name}")
# ✅ stderr に流す
print(f"Tool called: {tool_name}", file=sys.stderr, flush=True)flush=True を付けておくのは、バッファリングで終了直前のログが消えるのを避けるためです。クラッシュ時こそログが欲しいのに、バッファに溜まったまま失われると原因が追えなくなります。
ロガーライブラリは要注意
Winston、pino、Bunyan、loguru といったロガーは、設定しない限りデフォルトで stdout に書きます。「console.log は書いていないから大丈夫」と油断していると、実はロガーが静かにプロトコルを壊していることがあります。
// pino の例: stderr に向ける
import pino from "pino";
const logger = pino({}, pino.destination(2)); // 2 = stderr
logger.info("server started"); // ← 安全# loguru の例: sink を stderr にする
from loguru import logger
import sys
logger.remove() # デフォルトの sink を外す
logger.add(sys.stderr) # 明示的に stderr を指定
logger.info("server started")私は以前、pino のデフォルト設定のまま本番相当の MCP サーバーを配布してしまい、「ローカルでは動くのに配った先で全員壊れる」という報告を受けたことがあります。ロガーの出力先は、サーバーを公開する前に必ず確認しておきたいポイントです。
Claude Code 側でのログの見方
stderr に流し始めたら、今度は Claude Code 側でそのログを探します。macOS / Linux なら次のパスに保存されています。
# Claude Code の MCP サーバーログ
ls -lt ~/Library/Logs/Claude/mcp-server-*.log # macOS
ls -lt ~/.cache/claude/logs/mcp-server-*.log # Linux の代表例ファイル名にサーバー名が含まれているので、自作サーバーに対応するものを tail -f で追いかけると、リクエストと自分のログが時系列で流れてくるのが見えます。ここまで来れば、あとは通常のサーバーデバッグと変わりません。
開発中に手元で早く確認したいときは、Claude Code から起動するのではなく、ターミナルで直接サーバーを起動し、手でリクエスト JSON を流すのが一番早いです。
# 手動で initialize リクエストを流す
echo '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}},"id":1}' | node ./mcp/server.jsこの実行では stdout に JSON-RPC レスポンス、stderr にあなたのログが出るので、どちらが何を返しているかをターミナル上で直接切り分けられます。
落とし穴と回避策
実際に踏んだものを具体的に挙げておきます。
1. サードパーティ SDK が裏で stdout に書く
OpenTelemetry の自動計測や一部の DB ドライバは、初期化時に起動バナーを stdout に出すことがあります。疑わしい場合は、サーバー起動の最初の行で process.stdout.write をフックし、予期しない書き込みを stderr にリダイレクトする手が使えます。
2. unhandledRejection や uncaughtException の出力先
Node.js のデフォルトはエラーを stderr に出しますが、一部のフレームワークはこれを stdout に付け替えます。サーバーのエントリポイントに process.on("uncaughtException", err => console.error(err)) を明示的に書いておくと確実です。
3. 本番では stderr も捨てたい気持ちになる
パフォーマンスを気にして stderr をゼロにしたくなりますが、問題発生時の診断材料がなくなります。ログレベルを環境変数で切り替える設計(MCP_LOG_LEVEL=error)にして、stderr は常に生かしておくほうが後で助かります。
30秒でできる切り分けレシピ
コードを触る前に、本当に stdout が原因かを確かめる方法を紹介します。ターミナルから手元で initialize リクエストを投げ、stdout と stderr を別ファイルに分けて保存するだけです。
echo '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0.1"}},"id":1}' \
| node ./mcp/server.js \
> /tmp/mcp.stdout 2> /tmp/mcp.stderr/tmp/mcp.stdout には整形済みの JSON が 1 件だけ入っているはずです。バナー文字列や複数行のテキストが混ざっていたら、それが汚染源です。/tmp/mcp.stderr にはログが何行あっても構いません。コードを読み直すより、このコマンド一発で切り分けたほうが早いことが多いので、覚えておいて損はありません。
非同期・子プロセスの落とし穴
ハンドラの中で非同期に子プロセスを起動する場合、その子の stdout が親の stdout に漏れる事故があります。MCP サーバーとしては致命的です。
// ❌ 子プロセスの stdout を継承してしまう(= MCP の通信路に流れ込む)
const child = spawn("convert", args, { stdio: "inherit" });
// ✅ stdout だけは切り離す。stderr はログとして受け取ってよい
const child = spawn("convert", args, {
stdio: ["ignore", "ignore", "inherit"],
});Python の subprocess.run(..., stdout=subprocess.PIPE) はデフォルトで安全ですが、レガシーなラッパーが stdio=inherit 相当になっているケースは普通にあります。シェルアウトするツールハンドラを書いたら、自分のロガーの次に子プロセスの stdio 設定を疑うのが順序として正解です。
ストーム前に CI で防ぐ
本番で事故るより、CI で 1 行追加して防ぐほうが安上がりです。initialize リクエストを流して、stdout が「1 行・JSON のみ」であることを確認するだけの軽い smoke test で十分機能します。
import { spawn } from "child_process";
const p = spawn("node", ["./mcp/server.js"]);
p.stdin.write(JSON.stringify({
jsonrpc: "2.0", method: "initialize",
params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "t", version: "0.1" } },
id: 1,
}) + "\n");
let out = "";
p.stdout.on("data", (c) => (out += c));
setTimeout(() => {
const lines = out.trim().split("\n");
if (lines.length !== 1) throw new Error("stdout が汚染されています");
JSON.parse(lines[0]);
p.kill();
}, 500);このテストは決定的で、発生後に再現させるのが一番つらい症状を、マージ前に捕まえてくれます。
まず今日やること
自作 MCP サーバーを持っているなら、エディタで開いて console.log と print( を検索してください。見つかったものは全部 console.error と print(..., file=sys.stderr) に置き換える。ロガーを使っているなら destination が stderr になっているかを確認します。これだけで「なぜか動かない」系の症状の大半は消えます。残った問題は、stderr に出るログを見ながら切り分けられる、通常のサーバーデバッグの話に変わります。
MCP まわりで詰まったら、Claude Code MCP 接続エラー完全解決ガイド と Claude Code の /doctor コマンドで設定トラブルを3分で自己診断する もあわせて参考になると思います。