← ./articles-ja

MCP stdioサーバーのログはstderrへ: stdoutを汚さない設計

MCPサーバーをstdio transportで作るとき、最初に地味だけれど重要なのがログの出し先です。

Node.jsなら、つい console.log() で起動ログやデバッグログを出したくなります。しかしstdio transportでは、stdout(標準出力)がMCPのJSON-RPC通信路になります。つまり、stdoutは「人間向けログを流す場所」ではなく、「クライアントとサーバーがプロトコルメッセージを流す場所」です。

結論はシンプルです。MCP stdioサーバーのログはstderrへ出し、stdoutはプロトコル用に空けておくのが安全です。

stdio transportではstdoutが通信路になる

MCPの仕様では、標準transportの1つとしてstdioが定義されています。stdio transportでは、クライアントがMCPサーバーを子プロセスとして起動し、標準入力と標準出力を使ってJSON-RPCメッセージをやり取りします。

ざっくり言うと、流れはこうです。

MCP client
  -> server stdin: JSON-RPC request
  <- server stdout: JSON-RPC response / notification

この構造では、サーバーのstdoutに余計な文字列が混ざると、クライアントから見ると「プロトコルメッセージの流れに、人間向けログが混ざった」状態になります。

一部のSDKやクライアントは、単純な非JSON行を無視してくれる場合があります。実際に小さなNode.js MCPサーバーで確認したところ、起動時やツール実行時に console.log() を出しても、手元のTypeScript SDKクライアントは単純なケースでは応答を受け取れました。

ただし、これは「stdoutにログを出してよい」という意味ではありません。クライアント実装やログのタイミング、出力量、改行、バッファリングの挙動によって壊れ方が変わります。stdioのstdoutはプロトコル用、と決めておくほうが移植性が高いです。

悪い例: console.logで起動ログを出す

たとえば、次のような起動ログは避けます。

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

const server = new Server(
  { name: "example", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

async function main() {
  console.log("MCP server started"); // Avoid this in stdio servers
  await server.connect(new StdioServerTransport());
}

main();

console.log() はstdoutへ書きます。stdio transportではstdoutがJSON-RPC用なので、起動バナー、進捗表示、デバッグ出力、ツール内ログを混ぜないほうがよいです。

特に危ないのは、ツール実行中のログです。

server.setRequestHandler(CallToolRequestSchema, async () => {
  console.log("tool called"); // Avoid this
  return {
    content: [{ type: "text", text: "ok" }],
  };
});

ツール呼び出しの応答と同じタイミングでログがstdoutに出るため、クライアント側のパーサーやログ収集の実装によっては原因が見えにくい失敗になります。

良い例: stderrへログを出す

Node.jsなら、デバッグログはstderrへ出します。

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);

  process.stderr.write("MCP server running on stdio\n");
}

main().catch((err) => {
  process.stderr.write(`Fatal: ${String(err)}\n`);
  process.exit(1);
});

stderr(標準エラー)は、エラー専用という名前ですが、CLIツールでは「機械が読むstdout」と「人間が読む診断ログ」を分けるためにも使われます。MCP stdioサーバーでも同じ考え方が使えます。

ツール実行中のログもstderrへ寄せます。

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  process.stderr.write(`tool called: ${request.params.name}\n`);

  return {
    content: [{ type: "text", text: "ok" }],
  };
});

これでstdoutはJSON-RPC用、stderrは診断用、という分離ができます。

検証したこと

小さなNode.js MCPサーバーを2つ用意して、MCP SDKのstdioクライアントから listToolscallTool を呼びました。

1つ目はstderrへログを出すサーバーです。これは問題なく動きました。

2つ目は console.log() でstdoutへログを出すサーバーです。手元のTypeScript SDKクライアントでは、単純な起動ログとツール内ログは許容され、応答も返りました。

この結果から言えるのは、「今回のSDKは簡単なstdout汚染を許容した」という事実までです。MCP仕様上、stdioはstdin/stdoutを使うtransportなので、クライアント互換性を考えるならstdoutをログに使わない設計が安全です。

記事やドキュメントで「stdoutログは必ず壊れる」と断言するより、stdoutはプロトコルのために予約し、ログはstderrへ逃がすと説明するほうが正確です。

実装チェックリスト

MCP stdioサーバーを作ったら、次を確認します。

  • console.log() を使っていない
  • console.info()console.debug() もstdoutへ出る前提で避ける
  • 起動ログは process.stderr.write() へ出す
  • 例外ログもstderrへ出す
  • ツールハンドラ内のデバッグログもstderrへ出す
  • ツールの戻り値はMCP SDKの content レスポンスとして返す
  • stdoutへ直接 process.stdout.write() しない

ログをきれいに分けておくと、MCPクライアントで「接続できない」「ツール一覧が出ない」「応答が途中で切れる」といった症状が出たときに、原因を絞りやすくなります。

Markdown知識ベースMCPでの使いどころ

Markdown知識ベースをMCPで配信する場合も同じです。

たとえば get_article が記事本文を返すサーバーでは、記事本文そのものはMCPのレスポンスとして返します。一方、「何件の記事を読んだ」「どのslugを探した」といった診断ログはstderrへ出します。

stdout: MCP response only
stderr: loaded 12 articles
stderr: get_article slug=mcp-stdio-server-logging-stderr lang=ja

この分離を守ると、AIクライアントが受け取る本文と、人間が読むデバッグ情報が混ざりません。

MarkdownをMCPで配る設計全体については、MCPサーバーでMarkdown知識ベースを配信する設計: HTMLに変換しない理由 で整理しています。

まとめ

MCP stdioサーバーでは、stdoutをログ出力に使わないのが安全です。

stdio transportではstdoutがJSON-RPC通信路になります。手元のSDKが単純なログを許容する場合があっても、それに依存するとクライアントや環境が変わったときに壊れやすくなります。

MCPサーバーの基本方針は、stdoutをプロトコル用に保ち、ログや診断情報をstderrへ出すことです。地味なルールですが、MCPサーバーを安定して運用するための土台になります。

参考