Insights·2026-08-27

stdin とは何か

stdin(標準入力)はプログラムが入力を読み込む既定の通り道であり、ファイルディスクリプタ 0 番である。シェルでパイプ(|)やリダイレクト(<)でデータを渡すときに使われるのがこの通り道だ。プログラムは入力がキーボードから来たのかファイルから来たのか前のコマンドの出力から来たのかを知らなくてよく、接続はシェルが行う。Unix のパイプラインが成立する理由はここにある。ところが MCP では stdin の位置づけが変わる。stdio トランスポートにおいて stdin と stdout は、データを運ぶ通り道どころか通信チャネルそのものである。ローカルの MCP サーバーはクライアントが起動した子プロセスであり、ソケットもポートも開かずに改行区切りの JSON-RPC メッセージを stdin で受け取り stdout で返す。だからこそ仕様は、サーバーは有効な MCP メッセージでないものを自身の stdout に書いてはならない、と定めている。console.log 一行、ライブラリのバナー、npm の警告ひとつがその JSON ストリームに混ざればクライアントのパーサーは壊れる。MCP サーバーが接続できないと表示されたとき最初に疑うべき原因がこれであり、対処はすべてのログを stderr に送ることだ。

터미널 화면 안에 프로세스 상자가 있고, 0번 화살표가 상자로 들어가고 1번과 2번 화살표가 각각 다른 방향으로 나간다
통로 세 개: 0으로 읽고, 1로 내보내고, 2로 로그한다

stdin とは何か

ターミナルを使っていると stdin という語に出会う。標準入力と訳されるが、実体は単純だ。プログラムが入力を読み込む既定の通り道である。

OS はプロセスを起動するとき、番号のついた通り道を三つ既定で開く。この番号をファイルディスクリプタと呼ぶ。0 番が stdin、1 番が stdout(標準出力)、2 番が stderr(標準エラー)だ。プログラムは 0 番から読み、結果は 1 番へ、エラーやログは 2 番へ書く。

重要なのは、その通り道がどこにつながっているかをプログラムが知らなくてよい点だ。キーボードかもしれないし、ファイルかもしれないし、前のコマンドの出力かもしれない。接続はシェルが代わりに行う。プログラムはただ stdin を読むだけだ。Unix のパイプラインが成立する理由はまさにこれである。

そして 1 番と 2 番が分かれているという事実が、この記事の後半の核心になる。人に見せるログと次のプログラムへ渡すデータが混ざってはならないので、通り道は最初から二つに分けてある。

名前番号(FD)向き既定の接続先用途
stdin0入力キーボードプログラムが読み込むデータ
stdout1出力画面次のプログラムへ渡す結果
stderr2出力画面人が見るログ・エラー

CLI で stdin はどう使われるか

パイプ・リダイレクト・ハイフンでstdinにデータを渡す3つのコマンドが、コメント付きでターミナルに並んでいる。

使い方は大きく四つある。ひとつずつ実際に打ってみるのがいちばん早い。

第一にパイプ。前のコマンドの stdout を後のコマンドの stdin につなぐ。引数で渡すには長すぎるデータを扱うときに使う。cat error.log | claude -p "このログを要約して" と書けばログ全文が stdin から入る。claude -p は stdin にデータがあれば、それをプロンプトの前に付けて処理する。

第二にリダイレクト。ファイルを直接 stdin につなぐ。claude -p "説明して" < README.md という形だ。パイプと結果は似ているが、プロセスを一つ余分に起動しない。

第三にハイフン一つ。多くの CLI が、ファイル引数の位置に - を置くと stdin から読めという意味だと取り決めている。kubectl apply -f - や docker build -t img - のような形だ。これは仕様ではなく慣行なので、ツールごとに対応が違う。ヘルプで確かめる。

第四に heredoc。一時ファイルを作らずに複数行をそのまま stdin へ流し込む。SQL や設定の断片を渡すときに便利だ。

四つを実際に打ってみる
# 1) パイプ — 前のコマンドの stdout を後のコマンドの stdin へ
echo hello | cat
cat error.log | claude -p "このログを要約して"
git diff | claude -p "レビューして"

# 2) リダイレクト — ファイルを stdin につなぐ
claude -p "説明して" < README.md
wc -l < /etc/hosts

# 3) ハイフン一つ = stdin から読め(慣行)
curl -s https://example.com/a.json | jq . -
kubectl apply -f -

# 4) heredoc — 一時ファイルなしで複数行
cat <<'EOF' | sort
banana
apple
EOF

isatty — CLI が止まる本当の理由

stdinが端末かどうかを確認するコマンドと、入力待ちで止まったCLIを進める コマンドがターミナルに表示されている。

ここで初心者がいちばんよく引っかかる落とし穴が出てくる。cron や CI スクリプトで CLI が何も出力せずに止まってしまう現象だ。

原因はたいてい stdin である。プログラムが人に何かを尋ねようと入力を待っているのに、そこに人がいない。ターミナルならプロンプトが出て状況が見えるが、バックグラウンドでは何も見えないまま永遠に止まる。

だからきちんと作られた CLI は、stdin がターミナルにつながっているかをまず確かめる。この検査を isatty と呼ぶ。ターミナルなら人が前にいるということなので尋ね、そうでなければパイプで入ってきたデータをそのまま読む。同じコマンドが対話的にもスクリプトでも動くのは、この分岐のおかげだ。

他人の CLI が止まったときの対処は二つ。--non-interactive や -y のようなフラグがあればそれを付ける。なければ stdin に空のものをつなぐ。コマンドの末尾に < /dev/null を付ければ、プログラムはただちに入力の終わりに出会い、質問をあきらめる。cron スクリプトでこれを習慣にしておくと、原因不明の停止が大きく減る。

isatty の分岐と停止の回避
# Python で分岐する
import sys
if sys.stdin.isatty():
    name = input("名前: ")        # 人がターミナルの前にいる
else:
    data = sys.stdin.read()      # パイプで入ってきた

# シェルで確認 — ターミナルなら 0、パイプなら 1
[ -t 0 ] && echo "ターミナル" || echo "パイプ"

# 他人の CLI が入力待ちで止まったら
some-cli --non-interactive        # フラグがあればまずこれ
some-cli < /dev/null              # なければ stdin を空でつなぐ

MCP では stdin が通信チャネルそのものだ

ここから stdin の位置づけが変わる。ここまではデータを運ぶ通り道だったが、MCP の stdio トランスポートでは stdin と stdout が通信チャネルそのものである。

MCP 仕様はトランスポートを二つ定義している。ひとつは Streamable HTTP で、リモートサーバーが使う。もうひとつが stdio だ。仕様自身の説明のとおり、クライアントが起動した子プロセスの標準ストリーム上で改行区切りのメッセージをやり取りする。

つまりローカルの MCP サーバーは特別な何かではない。ただのプロセスだ。Claude Code のようなクライアントがそのプロセスを子として起動し、ソケットもポートも開かずにパイプで会話する。サーバーは stdin からリクエストを読み、stdout へレスポンスを書く。仕組みはそれだけである。

やり取りされるのは JSON-RPC 2.0 のメッセージで、一行にひとつだ。仕様は、メッセージは改行で区切られ、メッセージ内に改行を含んではならない、と定めている。だからパーサーは一行読んで JSON として解釈すればよく、その分だけ単純だ。

設定ファイルがしていることも正確にこれだけである。Claude Code の MCP 設定に書く command と args は、どの命令でプロセスを起動するかを言っているだけで、あとはそのプロセスの stdin と stdout につなぐだけだ。claude mcp add のヘルプが、stdio サーバーの登録例をハイフン二つの後に実行コマンドをそのまま書く形で示しているのも同じ話である。

stdio サーバーを登録する形
# ハイフン二つの後はそのまま実行される命令だ
claude mcp add my-server -- npx my-mcp-server

# 環境変数を渡しながら
claude mcp add my-server -e API_KEY=xxx -- npx my-mcp-server

# 登録済みのサーバーを確認
claude mcp list
claude mcp get my-server

# 対照:リモートサーバーは stdio ではなく HTTP でつながる
claude mcp add --transport http sentry https://mcp.sentry.dev/mcp
実際に流れるメッセージ — 一行にひとつ
→ サーバーの stdin へ:
{"jsonrpc":"2.0","id":1,"method":"tools/list"}

← サーバーの stdout から:
{"jsonrpc":"2.0","id":1,"result":{"tools":[...]}}

だから stdout にログを出すとサーバーが死ぬ

前節をそのまま裏返すと、MCP サーバー開発でいちばん多い事故が説明できる。stdout がプロトコルのチャネルである以上、そのチャネルにプロトコルでないものを書いてはならない。

仕様の一文は容赦がない。サーバーは有効な MCP メッセージでないものを自身の stdout に書いてはならない。MUST NOT である。推奨ではなく禁止だ。

ところがこの誤りは驚くほど簡単に起きる。console.log("サーバー起動") 一行で十分だ。自分で書かなくても起きる。使っているライブラリが初期化時にバナーを出す、npx がパッケージ取得時に警告を吐く、開発中に残したデバッグ出力がひとつ生き残っている——それがそのまま JSON ストリームに混ざる。クライアントのパーサーはその一行を JSON として解釈しようとして失敗し、接続が切れる。

だから MCP サーバーが接続できないと出たとき、まず確かめるのがこれだ。コード内で stdout へ出る出力をすべて探して stderr へ移す。仕様も、サーバーはログ目的で stderr に UTF-8 文字列を書いてよい、クライアントは stderr に出力があることをエラーの兆候と見なすべきではない、と明記している。stderr が仕様の認めるログ通路だ。

付け加えると、仕様は近年の改訂で、サーバーがクライアントへログを送る Logging 機能を非推奨とし、新しい実装は stderr へログを出すよう案内している。方向は同じだ。

直すべき書き方と直した結果
// プロトコルが壊れる — stdout はチャネルだ
console.log("サーバー起動");
console.log(JSON.stringify(debugState));

// 正常 — ログは stderr へ
console.error("サーバー起動");
process.stderr.write(`[debug] tool called: ${name}\n`);

// Python も同じ
print("サーバー起動")                        # 壊れる
print("サーバー起動", file=sys.stderr)        # 正常

// 誤りを元から断ちたいなら入口で塞いでおく
console.log = console.error;
stdout がきれいか確かめる
# サーバーをそのまま起動して stdout に何が出るかを見る。
# JSON 以外の文字が一行でも見えたら、それが原因だ。
node my-server.js < /dev/null

# stderr を捨てて stdout だけ残すとさらにはっきりする
node my-server.js < /dev/null 2>/dev/null

クライアントなしでサーバーを直接つついてみる

MCP サーバーが stdin と stdout で話すだけのプロセスだという事実には、実用的な見返りが伴う。Claude Code を経由せずにサーバーを直接試せることだ。

リクエスト JSON を一行、パイプでサーバーの stdin へ流し込めばよい。ツール一覧を尋ねる tools/list がいちばん簡単な試験だ。レスポンス JSON がそのまま返ってくればサーバーは正常。返ってこない、あるいは妙な文字が混ざるなら stdout が汚れている。

この方法が有用なのは、問題を絞り込んでくれるからだ。クライアントで接続できないとき、原因がサーバーのコードなのか設定なのかクライアントなのか区別がつかない。直接つついて応答が来れば、サーバーは無罪であり、設定かクライアント側を見ればよい。

もっと楽な道具もある。MCP 公式の Inspector はサーバーを起動して、ツール一覧や呼び出し結果をブラウザやターミナルで確認させてくれる。手で JSON を作らなくてよいので、繰り返しの作業にはこちらがよい。

Claude Code の中でサーバーの stderr ログを見るにはデバッグモードを入れる。かつて案内されていた --mcp-debug は今のヘルプにない。現在は -d または --debug にカテゴリのフィルタを渡す形で、ファイルに残すなら --debug-file にパスを渡す。頼る前に一度ヘルプを確かめるのが安全だ。

直接つついてみる
# tools/list リクエストをサーバーの stdin へ直接流し込む
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node my-server.js

# 正常なサーバーはこう返す
# {"jsonrpc":"2.0","id":1,"result":{"tools":[...]}}

# 公式 Inspector で起動して確認する
npx @modelcontextprotocol/inspector node my-server.js

# Claude Code でデバッグログを見る(--mcp-debug ではない)
claude --help | grep -i -E 'mcp|debug'
claude -d mcp
claude --debug-file /tmp/claude-debug.log

まとめ — stdin の話はどこまでか

stdin そのものは単純だ。プログラムが入力を読む 0 番の通り道で、そこにキーボードでもファイルでも前のコマンドの出力でもシェルがつないでくれる。CLI ではパイプ・リダイレクト・ハイフン・heredoc の四つの形で出会い、人のいない環境で止まらないためには isatty の分岐か < /dev/null がいる。

MCP はその通り道を通信チャネルとして使う。その決定ひとつから残りがすべて出てくる。ローカルサーバーがプロセスであることも、設定に命令と引数しか書かないことも、stdout にログを出してはいけないことも、ログが stderr へ行くべきことも、クライアントなしで echo 一行でサーバーを試せることも、同じ事実の別の顔だ。

だから自作の MCP サーバーがつながらないときの順序はこうなる。まず stdout に JSON でないものが出ていないかを見る。出ていればそれを stderr へ移す。それでもだめなら echo で直接つつき、サーバーとクライアントのどちらの問題かを分ける。この二段階でたいていは終わる。

最後に範囲をはっきりさせておく。ここまでの話は stdio トランスポートにのみ当てはまる。リモートの MCP サーバーは Streamable HTTP でつながるので stdin とは無関係であり、stdout に何を出してもプロトコルは壊れない。ローカルに入れて使うサーバーは、その多くが stdio だ。