第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_alerts と get_forecast が、いません。
見え方は、あなたの環境やタイミングで少し変わります。だいたい次のどれかです。
| 見え方 | 起きていること |
|---|---|
| ツールが一覧に出てこない | サーバーとの会話が成立していない |
| サーバー名の横に警告マークが出る | ホストが「繋がらない」と判断した |
| 「起動中…」のまま固まる | 返事を待ち続けている(後述の”最悪パターン”) |
| 一見動くが、ログにエラーが溜まる | 会話は続いているが、ゴミが混ざっている |
そして——ここが理不尽なところですが——画面には「console.log が悪い」とは一言も出ません。
😔 これが第0章で言った「これはあなたのせいではない」の正体です。 あなたは正しいコードを書きました。文法エラーもありません。
npm run buildも成功しています。それでも動かない。エラーが出ないことが、この技術のいちばん難しいところです。
🤔 なぜ壊れたのか 🟢
「画面」だと思っていた場所は、Claudeとの通話口だった
第1章の🔧節を思い出してください。stdio(標準入出力)では、こうなっていました。
- Claude Desktop が、あなたのプログラムを裏でこっそり起動する
- あなたのプログラムの標準入力(stdin)に、Claudeが話しかける
- あなたのプログラムが標準出力(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
💡
appendFileSyncのSync(同期)は「書き終わるまで待つ」という意味です。本番の高速なサーバーでは嫌われますが、ログが確実に残るので、学習中はむしろこちらが安心です。落ちる直前のログが消えません。
(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では stdio と Streamable HTTP の2つ |
➡️ 次へ
この章で、壊し方と直し方を1つ覚えました。まとめるとこれだけです。
- stdio では、stdout は Claude との通話口。ゴミを書くのは規格で MUST NOT。しかもたまに動いてしまうのがタチが悪い(改行つきなら該当行だけ捨てられて会話が続くことがあり、改行なしなら次のメッセージごと潰れて固まる)
- ログは
console.error(stderr) に書く。これが基本 - 確実に残したいならファイル追記、規格に乗るなら
sendLoggingMessage(capabilities: { logging: {} }を忘れずに) - HTTP なら逆になる。だから規格のログ機能に価値がある
- 自分が書いていなくても、ライブラリが stdout を汚すことがある
でも、まだ足りません。今回は「原因が console.log だと分かっている」状態でした。現実の「動かない」は、原因が分からないところから始まります。
第4章 デバッグの作法 — 見えない通信を覗く では、その切り分けの手順を作ります。ターミナルで直接起動する、MCP Inspector で覗く、ホストのログを読む、DevTools を開く——上から順に試せば必ず犯人にたどり着くフローチャートを用意します。
この2章が、この教材のいちばんの持ち帰りです。ここを抜ければ、第5章から自分のサーバーを、怖がらずに書き始められます。
関連ページ
- MCPサーバーを作って学ぶ AIに道具を持たせる入門 — はじめに・目次 — 目次
- 第1章 「コネクタ」の正体 — MCPは道具の共通規格 — stdio の予告をした章
- 第2章 まず公式のサンプルを動かす — 天気サーバーをClaudeに繋ぐ — この章で壊した天気サーバー
- 第4章 デバッグの作法 — 見えない通信を覗く — 次章。原因が分からないときの手順
- 第8章 HTTPで動かす — もう1つの繋ぎ方 — stdout / stderr の話が逆転する章