第3章 標準入出力の正体 — console.log 1行でサーバーが壊れる

📖 MCPサーバーを作って学ぶ AIに道具を持たせる入門 — はじめに・目次 ← 目次に戻る 第2章で天気サーバーが動きました。この章では、それをわざと1行で壊します。そして直します。壊れ方を自分の手で見ておくと、この先「動かない」に出会ったとき、慌てなくなります。


第1章の終わりで、こう予告しました。

⚠️ 画面に文字を出すつもりで console.log("started") と書くと、サーバーが丸ごと壊れます。

戻ってきました。今からこれを、本当にやります

この章は、この教材でいちばん実害のある知識を扱います。世の中のMCP入門記事は「動きました!」で終わりますが、あなたが自分のサーバーを書き始めた瞬間、真っ先にぶつかるのがこの罠です。しかもエラーメッセージが親切ではありません。知らないと、半日溶けます。


📱 Claude ではこう見える 🟢

まず、壊します

第2章で作った weather フォルダを開いてください。src/index.tsいちばん上に、たった1行足します。

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

console.log("started");   // ★この1行を足すだけ

「サーバーがちゃんと起動したか確認したいな」——初心者がごく自然に書く1行です。悪意も、間違いもありません。プログラミングの入門書に書いてあるとおりの書き方です。

ビルドして、

npm run build

Claude Desktop を完全に終了して、起動し直します(ウィンドウを閉じるだけではダメでした。第2章でやりましたね)。

何が起きるか

Claude Desktop を開いて、道具のアイコン(🔨 や「検索とツール」)を見てください。さっきまであった get_alertsget_forecast が、いません。

見え方は、あなたの環境やタイミングで少し変わります。だいたい次のどれかです。

見え方 起きていること
ツールが一覧に出てこない サーバーとの会話が成立していない
サーバー名の横に警告マークが出る ホストが「繋がらない」と判断した
「起動中…」のまま固まる 返事を待ち続けている(後述の”最悪パターン”)
一見動くが、ログにエラーが溜まる 会話は続いているが、ゴミが混ざっている

そして——ここが理不尽なところですが——画面には「console.log が悪い」とは一言も出ません

😔 これが第0章で言った「これはあなたのせいではない」の正体です。 あなたは正しいコードを書きました。文法エラーもありません。npm run build も成功しています。それでも動かない。エラーが出ないことが、この技術のいちばん難しいところです。


🤔 なぜ壊れたのか 🟢

「画面」だと思っていた場所は、Claudeとの通話口だった

第1章の🔧節を思い出してください。stdio(標準入出力)では、こうなっていました。

  1. Claude Desktop が、あなたのプログラムを裏でこっそり起動する
  2. あなたのプログラムの標準入力(stdin)に、Claudeが話しかける
  3. あなたのプログラムが標準出力(stdout)に書いたものを、Claudeが聞く

つまり。

console.log が文字を書き込む先=標準出力=Claudeとの通話口そのもの。

普段プログラムを書くとき、console.log の出力先は「ターミナルの画面」です。人間が読むための場所。ところが MCPサーバーには画面がありません。第1章で「画面のない小さなプログラム」と言ったとおりです。その代わりに、そこにはClaudeがいます

console.log("started") は、画面に文字を出したのではありません。Claudeに向かって “started” と叫んだのです。

会話の中身を見てみましょう

Claude とあなたのサーバーは、JSON-RPC という決まった形の文字列をやりとりしています。「1行=1メッセージ」で、改行で区切ります。実物はこんな感じです。

→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{ ... }}
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25", ... }}
→ {"jsonrpc":"2.0","id":2,"method":"tools/list"}
← {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"get_alerts", ... }]}}

きれいに { で始まって } で終わる行が、交互に並んでいます。ここに console.log("started") を足すと、実際の標準出力はこうなります(本当に手元で動かして取った出力です)。

started
{"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"weather","version":"1.0.0"}},"jsonrpc":"2.0","id":1}

1行目の started。これを受け取った側は、JSONとして読もうとして、こう言います。

Unexpected token 's', "started" is not valid JSON

「s って何? JSONじゃないんだけど」——これが、あなたが遭遇するエラーの正体です。

図で見ると

【正常】
  あなたのサーバー          Claude Desktop
       │                        │
  stdout ─── {"jsonrpc":...} ──→ 🙂 読めた
       │                        │
  stderr ─── (ログ) ──────────→ 📝 ログ置き場へ


【壊れた】
  あなたのサーバー          Claude Desktop
       │                        │
  stdout ─── "started" ───────→ 😵 JSONじゃない!
       │                        │
       └──── {"jsonrpc":...} ──→ (もう会話が乱れている)

人間でたとえるなら——通訳さんが日本語と英語を訳している最中に、横から第三者が「はじまりましたー!」と叫んだ状態です。通訳は「今のは訳すべき言葉? それとも雑音?」と混乱します。

「たまに動いてしまう」のが、いちばんタチが悪い

ここは正直に書きます。壊れ方には2段階あります。

console.log("started")(末尾に改行がつく場合)

console.log は自動的に末尾へ改行を足します。だから startedそれ自体で1行になります。この場合、受け取る側によっては「この行は読めなかった」とエラーを出しつつ、次の行から会話を続けてしまうことがあります。

実際、公式SDKのクライアントで試すとこうなりました。

TRANSPORT ERROR -> Unexpected token 's', "started" is not valid JSON
listTools OK: [ 'get_alerts' ]

エラーは出ているのに、ツール一覧は取れている。 一見「動いている」のです。

これは救いではなく、です。なぜなら——

  • 環境によっては動き、環境によっては動かない
  • 今日は動き、明日は動かない
  • あなたの手元では動き、他人の手元では動かない

という、いちばんデバッグしづらい不具合になるからです。ホスト(Claude Desktop)がどこまで寛容かは、規格が保証していません。「寛容だったらラッキー」に頼ったコードを書いてはいけません。

② 改行のつかない出力(こちらが本当に致命的)

ライブラリが process.stdout.write("...") のように改行なしで書いた場合、話が変わります。ゴミ文字列が、次のJSONメッセージの先頭にくっつくからです。

started{"result":{"protocolVersion":"2025-11-25", ...

こうなると、started だけでなくそのメッセージ丸ごとが読めなくなります。手元で試したところ、こうなりました。

TRANSPORT ERROR -> Unexpected token 's', "started{"r"... is not valid JSON
(そのあと、いつまでも返事が来ない → 固まる)

つぶれたのが initialize への返事だったので、Claude 側は「返事待ち」のまま永遠に待ち続けます。タイムアウトするまで、うんともすんとも言いません。これが上の表の「起動中…のまま固まる」パターンです。

📖 規格はこう言っていますMCP仕様 / Transports

  • メッセージは改行で区切られ、メッセージの中に改行を含んではいけない(MUST NOT)
  • サーバーは、有効なMCPメッセージ以外を stdout に書いてはいけない(MUST NOT)
  • サーバーは、ログ目的で stderr に UTF-8 の文字列を書いてもよい(MAY)。クライアントはそれを拾っても、転送しても、無視してもよい

MUST NOT は規格用語で「絶対にダメ」という意味です。「なるべく避けて」ではありません。


🛠 こう直す — 3つのやり方 🟢

console.log を消せば直ります。でもそれだとログが1つも残らない。デバッグができません。

そこで「ログは残しつつ、会話は壊さない」方法を3つ紹介します。まずは (a) だけ覚えれば十分です。

(a) console.error を使う = stderr に書く 🟢 ←まずこれ

いちばん簡単で、公式クイックスタートもこれを使っています。

console.error("started");   // ← log を error に変えただけ

console.log は stdout に、console.error は stderr(標準エラー出力)に書きます。 そして stderr は、Claudeとの通話口ではありません。規格が「ログを書いてよい(MAY)」と認めた、別の口です。

だから第2章のコードは、こうなっていました。

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Weather MCP Server running on stdio");   // ★log ではなく error
}

main().catch((error) => {
  console.error("Fatal error in main():", error);
  process.exit(1);
});

「起動しました」というめでたいメッセージなのに console.error を使っているのは、変に見えたかもしれません。理由はこれです。 error という名前は「エラーのとき用」ではなく、「stderr に書く」という意味だと思ってください。

そして stderr に書いたものは、捨てられるわけではありません。Claude Desktop がログファイルに拾ってくれます。

# macOS
tail -n 20 -F ~/Library/Logs/Claude/mcp*.log
# Windows
type "$env:AppData\Claude\logs\mcp*.log"

この章の最重要ルール stdio サーバーでは、console.log を書かない。ログは全部 console.error 迷ったらこれだけ守ってください。

(b) ファイルに書く自前ロガー 🟢

stderr のログは、ホストのログ置き場に混ざります。「自分のサーバーのログだけ、自分の好きな場所に、じっくり残したい」ときは、ファイルに追記するのが確実です。

import fs from "node:fs";

const logFile = "/tmp/weather-server.log";   // ← 好きな絶対パスに

function log(message: string) {
  fs.appendFileSync(logFile, `${new Date().toISOString()} ${message}\n`);
}

1行ずつ見ます。

  • import fs from "node:fs" … ファイルを扱うNode標準の道具を読み込みます
  • const logFile = ... … 書き出し先。必ず絶対パスにしてください。サーバー起動時のカレントディレクトリは決まっていないので、./log.txt のような相対パスだとどこに出たか分からなくなります(第2章の「絶対パス必須」と同じ理由です)
  • fs.appendFileSync(...)追記します。writeFileSync だと毎回まっさらになってしまいます
  • new Date().toISOString() … 時刻を頭に付けます。「いつ呼ばれたか」はデバッグで効きます
  • 末尾の \n … 改行を自分で足します。appendFileSync は付けてくれません

使い方はこうです。

async ({ state }) => {
  log(`get_alerts called with state=${state}`);
  // …処理…
}

別のターミナルで、流れるログを眺められます。

tail -F /tmp/weather-server.log
2026-07-18T21:45:41.007Z get_alerts called with state=CA

💡 appendFileSyncSync(同期)は「書き終わるまで待つ」という意味です。本番の高速なサーバーでは嫌われますが、ログが確実に残るので、学習中はむしろこちらが安心です。落ちる直前のログが消えません。

(c) sendLoggingMessage = MCPの正式なログ機能 🔧

MCPにはログを送るための仕組みが規格として用意されています。ホストに「これはログです」と伝わるので、対応しているホストなら画面に表示してくれます。

そして最大の利点は——stdio でも HTTP でも、同じコードで動くことです。

まず、サーバーに「ログを送ります」と宣言します。 これを忘れると何も起きません(後述)。

const server = new McpServer(
  { name: "weather", version: "1.0.0" },
  { capabilities: { logging: {} } },   // ★これが宣言
);

送るときはこう書きます。

await server.server.sendLoggingMessage({
  level: "info",
  data: `get_alerts: ${state}`,
});

server.server と2回続くのが気持ち悪いですが、打ち間違いではありませんMcpServer(書きやすい高レベルの箱)の中に、server という低レベルの本体が入っていて、通知を送る機能はそちらにあります。

  • level … 深刻度。RFC 5424 という古くからある規格の8段階です
  • data … 中身。文字列でも、オブジェクトでもかまいません(JSONにできるものなら何でも)

level に書ける8つは、軽い順にこうです。

level どんなとき
debug 細かい追跡(関数に入った/出た)
info ふつうの進捗
notice 正常だけど重要な出来事
warning 警告
error 失敗した
critical 部品が壊れた
alert すぐ手を打つ必要がある
emergency もうダメ

受け取る側は logging/setLevel というお願いを送って「warning 以上だけちょうだい」と絞り込めます。うるさすぎるログを、使う側が黙らせられるわけです。

実際に手元で動かすと、クライアント側にこう届きます。

LOG NOTIFICATION -> {"level":"info","data":"get_alerts: CA"}

⚠️ 最大の落とし穴:capabilities: { logging: {} } を書き忘れると、sendLoggingMessage は静かに何もしません。 エラーも出ません。例外も飛びません。ただ、届かないだけです。「ログが出ない…」と何十分も悩む定番パターンなので、セットで覚えてください。

🔧 どれを使う? 学習中は (a) console.error が9割です。それで足りないとき (b) ファイル。(c) は「他の人にも配るサーバーを作る」ときや、HTTPでも動かしたいとき(第8章)に効いてきます。

3つの比べかた

やり方 書きやすさ どこに出る stdio HTTP
(a) console.error ★★★ ホストのログファイル ⚠️ クライアントには届かない
(b) ファイル追記 ★★ 自分が決めた場所
(c) sendLoggingMessage ホストの画面・ログ

🤔 なぜ HTTP なら console.log してもいいのか 🟢

ここまで「stdout は聖域だ」と言ってきましたが、それは stdio のときだけの話です。

第1章で「話し方はもう1つ、HTTP というやり方もある」と触れました。Streamable HTTP で動かすサーバーでは、Claudeとの会話はネットワーク越しに行われます。stdout は会話に使われていません。だから——

HTTP トランスポートなら、console.log を書いても壊れません。 stdout はただの画面に戻ります。

逆に、こちらでは別のことが起きます

HTTP では、console.error(stderr)はクライアントに拾われません。 サーバーは別のマシンで動いているかもしれないのですから、当然です。

つまりまったく逆になるわけです。

  stdio Streamable HTTP
stdout に console.log 壊れる ✅ 問題なし
stderr に console.error ✅ ホストが拾う ⚠️ クライアントには届かない
sendLoggingMessage ✅ 届く ✅ 届く

だからこそ (c) の sendLoggingMessage に意味があります。トランスポートを乗り換えても、ログのコードを書き直さなくていいからです。第8章でHTTPに移すとき、この表に戻ってきます。

💡 ここでも背骨①(規格)が効いています。「規格で決まった方法でログを送る」から、下の配管が変わっても、上のコードは無事なのです。


⚠️ ハマりどころ 🟢

この章の本番です。上の3つの直し方より、こちらのほうが実際に時間を溶かします。

console.log を消したのに直らない → ビルドを忘れている

ダントツ1位です。 断言します。あなたも必ずやります。

思い出してください。Claude Desktop の設定ファイルが指しているのは、こうでした。

"args": ["/ABSOLUTE/PATH/TO/weather/build/index.js"]

src/index.ts ではなく、build/index.js あなたが直したのは src のほう。Claude が読んでいるのは build のほう。つまり——

npm run build を走らせるまで、あなたの修正はこの世に存在していません。

直したのに直らないときの手順は、いつもこれです。

npm run build            # ← 1. ビルドし直す

そして 2. Claude Desktop を完全終了して再起動。ウィンドウの ✕ ではなく、アプリごと終了(macOS は Cmd+Q、Windows はタスクトレイからも終了)。

疑わしいときは、ビルド後のファイルを直接見て確認するのが確実です。

grep -n "console.log" build/index.js

何も出なければ、きれいです。 何か出たら、まだ直っていません。

print デバッグの癖が抜けない

長年しみついた癖は、意識しても出ます。「ちょっとだけ値を見たい」ときに、指が勝手に console.log と打ちます。

対策は、気合ではなく、仕組みにしてください。

対策1:最初にロガーを1個だけ作って、console.log を封印する

function log(message: string) {
  console.error(message);   // 中身は console.error
}

以後、書くのは log(...) だけconsole と打たない、と決めてしまえば事故が減ります。

対策2:コミット前に必ず1回検索する

grep -rn "console.log" src/

対策3:エディタや AI に見張らせる

CLAUDE.md や .cursorrules に、こう1行入れておきます。

このプロジェクトは stdio の MCPサーバーです。console.log は絶対に使わないでください。ログは console.error を使ってください。

③ ★自分では書いていないのに、ライブラリが stdout に出す★

この章でいちばん覚えて帰ってほしい注意です。 上の①②は自分のミスなので grep で見つかります。これは見つかりません。

あなたのコードに console.log が1つも無くても、あなたが npm install したライブラリが、勝手に stdout へ書くことがあります。

  • 起動時に「ロゴ」や「Thanks for using ○○!」を出すライブラリ
  • 「バージョン 2.0 が出ました」というお知らせ
  • 「この関数は非推奨です」という deprecation warning
  • 「.env を読み込みました」といった親切なお知らせ
  • デバッグモードが既定でオンになっているライブラリ

どれも、ふつうのCLIツールなら親切な挙動です。しかし MCPサーバーの stdout では、ただのノイズであり、会話を壊します。しかも改行なしで書かれた日には、上で見た「固まる」パターンに直行します。

症状の見分け方grep -rn "console.log" src/ が空っぽなのに壊れる。このときはライブラリを疑ってください。

確かめ方:ターミナルで直接サーバーを起動して、stdout に何が出るか自分の目で見ます

node build/index.js

正常なら、画面には何も出ませんconsole.error のメッセージは stderr なので表示されますが、それは stdout ではありません)。ここで挨拶文やバージョン情報が出たら、それが犯人です。

stdout だけを取り出すなら、stderr を捨てて見ます。

node build/index.js 2>/dev/null

ここに1文字でも出たら、アウトです。

対処

  • そのライブラリに「静かにする」設定があれば使う(silent: true、環境変数の NO_COLOR / CI=true など。ライブラリ次第です)
  • 無ければ、そのライブラリを使わないか、別のものに替える
  • どうしても必要なら、起動直後に process.stdout.write を差し替えて stderr に流す、という荒業もありますが、初心者のうちは避けてください。SDK自身の出力まで巻き込むと、今度は本当に何も動かなくなります

💡 道具を選ぶときの新しい目線:MCPサーバーに組み込むライブラリは、「黙っているか」で選ぶ価値があります。おしゃべりなライブラリは、ここでは欠点になります。

④ ログに書いた内容が、そのまま人に見える

console.error で書いたものは、ホストのログファイルに平文で残ります。うっかりこう書かないでください。

console.error(`APIキー: ${apiKey}`);          // ❌ 絶対ダメ
console.error(`メモの中身: ${fullContent}`);   // ❌ プライバシー

MCPの仕様も、ログに認証情報・個人情報・攻撃の手がかりになる内部情報を含めてはいけない(MUST NOT)と明記しています。ログに出すのは「何が起きたか」だけにして、「中身」は出さない。

console.error(`get_alerts called (state length: ${state.length})`);   // ✅

この感覚は、第9章(安全)でもう一度扱います。

⑤ 動かない原因を console.log だと決めつける

逆方向のハマりです。この章を読んだ直後は、何でも console.log のせいに見えます。でも「ツールが出てこない」原因は、実際にはこれくらいあります。

  • ビルドしていない
  • パスが相対パスになっている
  • 設定ファイルのJSONにカンマの打ち間違いがある
  • Claude Desktop を再起動していない
  • そもそもコードが起動時に落ちている

切り分けの手順そのものは、次の第4章のテーマです。この章で覚えるのは「stdout は聖域」という1点で十分です。


🤖 AIに頼むなら 🟢

AIも console.log を書きます

MCPサーバーを AI に書かせると、平気で console.log を混ぜてきます。AIが学習した世界では、Node.js のログは console.log が圧倒的多数派だからです。

なので、頼むときに先回りしてください。

「stdio の MCPサーバーなので、console.log は使わないでください。ログは console.error でお願いします」

プロジェクトに CLAUDE.md.cursorrules があるなら、そこに書いておくのが一番確実です。毎回言わなくて済みます。

「動かない」と丸投げしない

第1章でも書きましたが、AIはあなたのパソコンで何が起きているかを見られません。「動きません」だけ投げても、当てずっぽうが返ってくるだけです。

こう聞いてください。

node build/index.js 2>/dev/null を実行したら、started という文字が出力されました。これは何が原因ですか」

症状を具体的な観測結果として渡すと、精度が跳ね上がります。しかも、この観測をした時点で、たいてい自分で原因が分かっています

レビューを頼むのも有効です

書き終えたコードに、こう頼むのも効きます。

「このMCPサーバーで、stdout に何かを書いてしまう可能性がある箇所を全部挙げてください。依存ライブラリの出力も含めて」

自分の目より、こういう網羅的なチェックは AI のほうが得意です。


📗 ことばメモ

ことば よみ 意味
標準出力 / stdout ひょうじゅんしゅつりょく プログラムが「結果」を書き出す口。stdio では Claude との通話口そのもの
標準エラー出力 / stderr ひょうじゅんえらーしゅつりょく プログラムが「ログや文句」を書き出す、もう1つの口。MCPではこちらが安全
標準入力 / stdin ひょうじゅんにゅうりょく プログラムが読み込む口。Claude からの発言が流れてくる
JSON-RPC ジェイソン アールピーシー MCPが使う会話の形式。1行=1メッセージ、改行で区切る
MUST NOT マスト ノット 規格用語で「絶対にしてはいけない」。SHOULD(推奨)より強い
MAY メイ 規格用語で「してもよい」。stderr へのログはこれ
RFC 5424 アールエフシー ごーよんにーよん ログの深刻度を8段階で決めた古典的な規格。MCPのログもこれに従う
deprecation warning デプリケーション ワーニング 「この機能は将来なくなります」という警告。stdout に出ると事故る
トランスポート 会話の通り道。MCPでは stdioStreamable HTTP の2つ

➡️ 次へ

この章で、壊し方と直し方を1つ覚えました。まとめるとこれだけです。

  • stdio では、stdout は Claude との通話口。ゴミを書くのは規格で MUST NOT。しかもたまに動いてしまうのがタチが悪い(改行つきなら該当行だけ捨てられて会話が続くことがあり、改行なしなら次のメッセージごと潰れて固まる)
  • ログは console.error(stderr) に書く。これが基本
  • 確実に残したいならファイル追記、規格に乗るなら sendLoggingMessagecapabilities: { logging: {} } を忘れずに)
  • HTTP なら逆になる。だから規格のログ機能に価値がある
  • 自分が書いていなくても、ライブラリが stdout を汚すことがある

でも、まだ足りません。今回は「原因が console.log だと分かっている」状態でした。現実の「動かない」は、原因が分からないところから始まります

第4章 デバッグの作法 — 見えない通信を覗く では、その切り分けの手順を作ります。ターミナルで直接起動する、MCP Inspector で覗く、ホストのログを読む、DevTools を開く——上から順に試せば必ず犯人にたどり着くフローチャートを用意します。

この2章が、この教材のいちばんの持ち帰りです。ここを抜ければ、第5章から自分のサーバーを、怖がらずに書き始められます。

関連ページ