◉CLAUDE LABEN
●2.1.301 — vim の % モーション、VS Code パネルでの LaTeX 数式表示、使用量上限待ちのリトライ監視の上限が入りました(10/15)●2.1.299 — 200K トークン超で約54分放置した会話はアイドル圧縮。MCP の許可ルールは名指ししたサーバーだけに一致します●SONNET 4.5 — Claude API での提供終了は 11/30、残り45日。移行先は Sonnet 5.5●WINDOWS — 日本語の環境でだけフックが黙って壊れる報告があり、文字コードの確認が呼びかけられています●FAST MODE — Opus 5.5 の Fast mode がリサーチプレビューを終え、ベータヘッダーが不要になりました(10/15)●NEW — Cowork に渡したフォルダの先頭に、十行の約束ファイルを置いてみました●2.1.301 — vim の % モーション、VS Code パネルでの LaTeX 数式表示、使用量上限待ちのリトライ監視の上限が入りました(10/15)●2.1.299 — 200K トークン超で約54分放置した会話はアイドル圧縮。MCP の許可ルールは名指ししたサーバーだけに一致します●SONNET 4.5 — Claude API での提供終了は 11/30、残り45日。移行先は Sonnet 5.5●WINDOWS — 日本語の環境でだけフックが黙って壊れる報告があり、文字コードの確認が呼びかけられています●FAST MODE — Opus 5.5 の Fast mode がリサーチプレビューを終え、ベータヘッダーが不要になりました(10/15)●NEW — Cowork に渡したフォルダの先頭に、十行の約束ファイルを置いてみました
記事一覧/Claude Code
⟐ Claude Code/2026-04-24中級

自作 MCP サーバーのログが消える — Claude Code で stderr を正しく拾う

Claude Code で自作の MCP サーバーを書いていて、console.log を足したのにログがどこにも出ない。実はサーバー自体が応答しなくなっている、という落とし穴の正体と、stdio を汚染しない正しいログ出力の置き換え方をまとめました。

claude-code132mcp20troubleshooting61loggingstdio

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分で自己診断する もあわせて参考になると思います。

シェア

お読みいただきありがとうございます

Claude Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

⟐ Claude Code2026-05-24
Claude Code で連続する Edit が連鎖的に失敗するときの直し方 — old_string が消える・重複する問題
Claude Code が同じファイルへ Edit を連続で投げると、2件目以降が突然『not found』や『not unique』で落ちることがあります。原因は順序依存とコンテキスト二重化で、Read 再実行と分割パッチで救えます。
⟐ Claude Code2026-05-10
Claude Code の Glob と Grep が0件を返すときの原因切り分け
Claude Code の Glob/Grep ツールがファイルを見つけられないとき、.gitignore除外・隠しファイル・パターン記法の落とし穴を切り分け、適切な代替手段で確実に発見する手順をまとめます。
⟐ Claude Code2026-05-10
Claude Code の Read が大きなファイルを最後まで読まないとき — PARTIAL view の受け取り方と offset / limit の設計
Read が大きなファイルを途中までしか返さないとき、Claude Code は PARTIAL view の通知を出しています。通知の受け取り方と、行数ではなくバイト予算で offset / limit を決める方法を、実測値とともにお伝えします。