MCPサーバーをJSON-RPCでテストする|stdio最小ハーネス
MCP 2026-07-28のstdioサーバーをLLMクライアントなしで検査するNode.js最小ハーネスです。server/discover、必須_meta、tools/list、改行区切り、stdout汚染を切り分けます。
- mcp
- json-rpc
- stdio
- testing
- nodejs
- troubleshooting
- 情報確認
- 参考リンク
- 7件
- 更新性
- 長く使える
- 読了目安
- 約14分
仕様・料金・提供範囲が変わりやすいテーマは、公開日・更新日・情報確認日を分けて管理します。 導入前には必ず記事末尾の一次情報と公式ドキュメントで最新状況を確認してください。
結論
MCPサーバーのstdio接続を直すときは、ClaudeやIDEをいったん外し、子プロセスへ改行区切りのJSON-RPCを直接送り、返答のidと構造を検査するのが最短です。本稿のNode.jsハーネスは、MCP 2026-07-28 を明示し、最初に server/discover、次に tools/list を送ります。どちらにも必須の _meta を付けます。
現行の2026-07-28以降は、リクエストごとにversionとcapabilitiesを伝えるmodern方式です。initialize は2025-11-25以前のlegacy方式であり、現行の最小コードには入れません。
この記事の対象読者
この記事は、ローカルstdio型MCPサーバーを実装・公開するNode.js開発者、接続障害をクライアントとサーバーのどちら側か切り分けたい運用担当者向けです。MCPの全体像はMCP入門、パスや環境変数を含む一般的な不通診断はMCPサーバー接続チェックリストを先に参照してください。
ここではHTTP transport、認証、実際のツール呼び出し内容は扱いません。検査対象を「プロセス起動」「stdioフレーミング」「2026-07-28プロトコル」「ツール一覧」の四つに絞ります。
先に対象spec versionを決める
MCPのVersioning and Compatibilityは、2026-07-28以降をmodern、2025-11-25以前をlegacyとして区別しています。
| サーバーの世代 | バージョンの伝え方 | 最初の代表的な操作 | 本稿のコード |
|---|---|---|---|
| modern(2026-07-28以降) | 各リクエストの _meta | server/discover または対象RPC | 対象 |
| legacy(2025-11-25以前) | initialize で交渉 | initialize → initialized通知 | 対象外、後半に互換メモのみ |
| dual-era | まずmodern probeで判定 | stdioでは server/discover | 判定方針のみ説明 |
2026-07-28では「接続時に一度だけ共有した情報」を前提にしません。各リクエストに少なくとも次の必須メタデータを入れます。
io.modelcontextprotocol/protocolVersionio.modelcontextprotocol/clientCapabilities
io.modelcontextprotocol/clientInfo は必須ではありませんが、公式仕様は通常、毎回含めることを推奨しています。本稿も含めます。
stdioのwireルールを確認する
公式のstdio transport仕様では、クライアントがMCPサーバーを子プロセスとして起動します。サーバーはstdinから読み、stdoutへ書きます。
wire上のチェックポイントは次のとおりです。
- 一つのJSON-RPCメッセージを一行にする
- メッセージ内に未エスケープの実改行を入れない
- クライアントのstdin出力は有効なMCPメッセージだけにする
- サーバーのstdout出力は有効なMCPメッセージだけにする
- サーバーの通常ログはstderrへ出す
- 終了時はまずサーバーのstdinを閉じる
JSON.stringify(message) + "\n" なら、文字列中の改行はエスケープされ、末尾の一個の改行だけがフレーム境界になります。console.log("server started") をサーバー側のstdoutへ出すと、それ自体が不正なMCPメッセージになります。
送るserver/discoverはこれ
Discovery仕様のリクエストは、本文パラメーターを持たず、標準の _meta だけを含みます。
{ "jsonrpc": "2.0", "id": "discover-1", "method": "server/discover", "params": { "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": { "name": "raw-stdio-probe", "version": "1.0.0" }, "io.modelcontextprotocol/clientCapabilities": {} } }}成功応答の result.resultType は complete で、supportedVersions と capabilities を確認します。serverInfo は自己申告情報であり、認証やセキュリティ判断には使いません。
Node.js最小ハーネス
次を probe-mcp.mjs として保存します。Node.js 20以降の標準モジュールだけを使い、追加依存はありません。
import { spawn } from "node:child_process";import { createInterface } from "node:readline";
const [command, ...args] = process.argv.slice(2);if (!command) { console.error("Usage: node probe-mcp.mjs <command> [args...]"); process.exit(2);}
const VERSION = "2026-07-28";const CLIENT_INFO = { name: "raw-stdio-probe", version: "1.0.0"};const CLIENT_CAPABILITIES = {};
const child = spawn(command, args, { stdio: ["pipe", "pipe", "inherit"], windowsHide: true});
const pending = new Map();let nextId = 0;let protocolFailure = null;let closing = false;
const closePromise = new Promise((resolve) => { child.once("close", (code, signal) => resolve({ code, signal }));});
function requestMeta() { return { "io.modelcontextprotocol/protocolVersion": VERSION, "io.modelcontextprotocol/clientInfo": CLIENT_INFO, "io.modelcontextprotocol/clientCapabilities": CLIENT_CAPABILITIES };}
function failProtocol(error) { if (!protocolFailure) protocolFailure = error; for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(error); } pending.clear();}
child.on("error", (error) => { failProtocol(new Error("Failed to start server: " + error.message));});
child.stdin.on("error", (error) => { if (!closing) { failProtocol(new Error("Failed to write to server stdin: " + error.message)); }});
child.on("exit", (code, signal) => { if (!closing && pending.size > 0) { failProtocol( new Error( "Server exited with code=" + String(code) + " signal=" + String(signal) ) ); }});
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity});
lines.on("line", (line) => { if (line.length === 0) { failProtocol(new Error("Blank line on server stdout")); return; }
let message; try { message = JSON.parse(line); } catch { failProtocol( new Error("Non-JSON data on server stdout: " + JSON.stringify(line)) ); return; }
if ( message === null || typeof message !== "object" || Array.isArray(message) ) { failProtocol(new Error("JSON-RPC message must be an object")); return; }
if (message.jsonrpc !== "2.0") { failProtocol(new Error("jsonrpc must be exactly 2.0")); return; }
const hasId = Object.hasOwn(message, "id"); const hasMethod = Object.hasOwn(message, "method"); const hasResult = Object.hasOwn(message, "result"); const hasError = Object.hasOwn(message, "error");
if (!hasId) { const hasInvalidParams = Object.hasOwn(message, "params") && ( message.params === null || typeof message.params !== "object" || Array.isArray(message.params) );
if ( typeof message.method !== "string" || hasResult || hasError || hasInvalidParams ) { failProtocol(new Error("Invalid JSON-RPC notification")); return; }
console.error("Server notification:", JSON.stringify(message)); return; }
if (hasMethod) { failProtocol(new Error("Server must not send JSON-RPC requests over stdio")); return; }
if (typeof message.id !== "string" && typeof message.id !== "number") { failProtocol(new Error("Response id must be a string or number")); return; }
const key = typeof message.id + ":" + String(message.id); const entry = pending.get(key); if (!entry) { failProtocol( new Error("Unexpected response id: " + JSON.stringify(message.id)) ); return; }
if (hasResult === hasError) { failProtocol( new Error("Response must contain exactly one of result or error") ); return; }
if (hasError) { if ( message.error === null || typeof message.error !== "object" || Array.isArray(message.error) || !Number.isInteger(message.error.code) || typeof message.error.message !== "string" ) { failProtocol(new Error("Invalid JSON-RPC error object")); return; } }
pending.delete(key); clearTimeout(entry.timer);
if (hasError) { const error = new Error("JSON-RPC error: " + JSON.stringify(message.error)); error.rpcError = message.error; entry.reject(error); return; }
entry.resolve(message.result);});
function request(method, params = {}, timeoutMs = 5000) { if (protocolFailure) return Promise.reject(protocolFailure); if (child.stdin.destroyed || !child.stdin.writable) { return Promise.reject(new Error("Server stdin is not writable")); }
const id = "probe-" + String(++nextId); const key = typeof id + ":" + id; const message = { jsonrpc: "2.0", id, method, params: { ...params, _meta: requestMeta() } };
return new Promise((resolve, reject) => { const timer = setTimeout(() => { pending.delete(key); reject(new Error("Timed out waiting for " + method)); }, timeoutMs);
pending.set(key, { resolve, reject, timer }); child.stdin.write(JSON.stringify(message) + "\n", (error) => { if (!error) return;
const current = pending.get(key); if (!current) return;
pending.delete(key); clearTimeout(current.timer); current.reject( new Error("Failed to write " + method + ": " + error.message) ); }); });}
async function waitForExit(timeoutMs) { let timer; try { return await Promise.race([ closePromise, new Promise((resolve) => { timer = setTimeout(() => resolve(null), timeoutMs); }) ]); } finally { clearTimeout(timer); }}
async function stopServer() { closing = true; if (!child.stdin.destroyed && !child.stdin.writableEnded) child.stdin.end();
let exited = await waitForExit(2000); if (exited) return { ...exited, forced: false };
try { child.kill(); } catch (error) { throw new Error("Failed to terminate server: " + error.message); }
exited = await waitForExit(2000); if (!exited) { throw new Error("Server did not exit after forced termination"); }
return { ...exited, forced: true };}
async function main() { const discovery = await request("server/discover"); if (discovery.resultType !== "complete") { throw new Error("Discovery did not complete"); } if ( !Array.isArray(discovery.supportedVersions) || !discovery.supportedVersions.includes(VERSION) ) { throw new Error( "Server does not advertise target version " + VERSION ); }
console.log( "Discovery:", JSON.stringify( { supportedVersions: discovery.supportedVersions, capabilities: discovery.capabilities }, null, 2 ) );
if (!Object.hasOwn(discovery.capabilities ?? {}, "tools")) { console.log("Server does not advertise the tools capability."); return; }
const listed = await request("tools/list"); if (listed.resultType !== "complete" || !Array.isArray(listed.tools)) { throw new Error("Invalid tools/list result"); } if ( Object.hasOwn(listed, "nextCursor") && typeof listed.nextCursor !== "string" ) { throw new Error("Invalid tools/list nextCursor"); }
console.log( "Tools:", JSON.stringify( listed.tools.map((tool) => ({ name: tool.name, hasInputSchema: Boolean(tool.inputSchema), hasOutputSchema: Boolean(tool.outputSchema) })), null, 2 ) );
if (Object.hasOwn(listed, "nextCursor")) { console.log( "More tools are available. This minimal probe prints only the first page." ); }}
let testFailed = false;try { await main();} catch (error) { testFailed = true; console.error(error.stack ?? error.message); process.exitCode = 1;} finally { try { const shutdown = await stopServer(); if (!testFailed && protocolFailure) throw protocolFailure; if ( !testFailed && (shutdown.forced || shutdown.code !== 0 || shutdown.signal !== null) ) { throw new Error( "Abnormal server shutdown: code=" + String(shutdown.code) + " signal=" + String(shutdown.signal) + " forced=" + String(shutdown.forced) ); } } catch (error) { console.error(error.stack ?? error.message); process.exitCode = 1; }}このハーネスは、サーバーstdoutの空行、JSON以外のログ、不正なnotification、予期しないid、不正なerrorオブジェクト、result と error の同時出現、応答タイムアウトを失敗として扱います。サーバーからの妥当なnotificationはstderrへ表示します。tools/list に nextCursor がある場合、この最小版が表示するのは最初の1ページだけだと明示します。完全な一覧が必要なら、同じ _meta と返されたcursorを次の tools/list へ渡して繰り返してください。
終了時はstdinを閉じて2秒待ち、終了しなければ強制終了を試みます。正常なテスト後に非ゼロ終了、シグナル終了、強制終了、または終了待機中のプロトコル違反が発生した場合は、ハーネス自体も失敗終了します。
Windowsで実行する
PowerShellから、テストしたいサーバーの実コマンドと引数を後ろへ並べます。
node .\probe-mcp.mjs node .\dist\server.jsnpxで起動するパッケージなら、たとえば次の形です。
node .\probe-mcp.mjs npx -y @scope/example-mcp-serverハーネスが Discovery を出す前に落ちた場合は、次の順で分類します。
| 症状 | 主な層 | 確認すること |
|---|---|---|
Failed to start server | プロセス起動 | command、PATH、作業ディレクトリ |
Non-JSON data on server stdout | stdioフレーミング | サーバーログをstderrへ移す |
Timed out waiting for server/discover | 世代違いまたは停止 | 対象spec、stdin読取、legacy判定 |
JSON-RPC error -32602 | 現行メタデータ不備 | _meta の必須2項目 |
JSON-RPC error -32022 | modern版の不一致 | error.data.supported との共通版 |
| discover成功、toolsなし | 能力の違い | resources/prompts専用か確認 |
| tools/listの形が不正 | MCP応答 | resultType と tools |
公開前のローカル検証へ組み込むなら、e-Gov法令MCPのnpm公開手順のように、ビルド後の実ファイルを引数へ渡します。ソースを直接実行した結果だけでなく、配布物のentry pointも試してください。
dual-era互換は別レイヤーで実装する
上のコードは2026-07-28を実装したmodernサーバーのテスト専用です。旧サーバーも扱うdual-eraクライアントは、stdio仕様の互換手順に従って server/discover をprobeとして使います。
- 優先するmodern版を
_metaに入れてserver/discoverを送る DiscoverResultが返れば、supportedVersionsから相互対応版を選ぶ-32022 UnsupportedProtocolVersionErrorなど認識可能なmodernエラーなら、data.supportedから相互対応版を選ぶ- その他のエラー、または妥当な時間内に無応答ならlegacyと判定する
- dual-eraクライアントだけ
initializeへfallbackする
-32601 だけをlegacy判定条件にしないでください。legacy実装が未知メソッドへ返すコードは実装依存で、応答しない場合もあります。反対に、-32022 はmodernサーバーが対象版の不一致を明示するエラーなので、initialize へ戻してはいけません。
旧2025-11-25版のinitialize例
次はlegacyサーバーへfallbackすると判定した後だけ送る例です。2026-07-28の現行リクエスト例ではありません。
{ "jsonrpc": "2.0", "id": "init-1", "method": "initialize", "params": { "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "raw-stdio-probe", "version": "1.0.0" } }}initialize の成功応答を受けた後にだけ、legacyのinitialized通知を送ります。
{ "jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}この二つをmodernハーネスへ常時混ぜると、対象仕様が分からないテストになります。modern専用テスト、legacy専用テスト、dual-eraの判定テストをファイルまたはテストケースで分けてください。
JSON-RPCとしても検証する
MCPメッセージはJSON-RPC 2.0仕様に従います。MCP側では、リクエストidは文字列または整数で、nullは使わず、未完了リクエスト間で重複させません。応答idは元リクエストと一致させます。
最低限、テストで次を落とします。
jsonrpcが"2.0"でない- requestにidまたはmethodがない
- responseに
resultとerrorの両方がある、または両方ない - responseのidが未完了requestに対応しない
- 成功結果に期待する
resultTypeがない tools/listのtoolsが配列ではない
ツールの outputSchema と structuredContent まで検査する場合は、MCPのprovenance用outputSchema設計のように、一覧で得たスキーマへ実際の tools/call 結果を通します。
テストチェックリスト
- 対象spec versionを
2026-07-28と明記した - サーバーをクライアントの子プロセスとして起動した
- stdinへ有効なMCPリクエスト以外を書いていない
- stdoutの各行を一つのJSON-RPCメッセージとしてparseした
- サーバーのログをstderrへ分離した
-
server/discoverを最初のprobeにした - 毎回の
_metaにprotocolVersionとclientCapabilitiesを入れた - clientInfoも毎回同じ値で送った
-
supportedVersionsに対象版があることを確認した - requestとresponseのidを照合した
- notificationのmethodとerrorオブジェクトの型を検証した
- 成功結果の
resultTypeを確認した -
tools/listのtoolsを配列として確認した -
nextCursorがある場合、先頭ページだけの検査だと明示した - stdin書き込みエラーと異常終了を失敗として扱った
- 終了時はまずstdinを閉じた
- legacy互換テストをmodern専用テストから分離した
-
-32022でinitializeへfallbackしていない
よくある質問
2026-07-28版MCPでも最初にinitializeを送りますか?
いいえ。2026-07-28以降のmodern MCPに初期化ハンドシェイクはなく、各リクエストの_metaへprotocolVersionとclientCapabilitiesを入れます。server/discoverは対応版と能力を調べるために使います。initializeは2025-11-25以前のlegacy版にだけ使います。
サーバーのログをstdoutへ出してはいけないのはなぜですか?
stdioのstdoutはMCPメッセージ専用で、1行ごとにJSON-RPCとして解釈されます。通常のログが1行混ざるだけでJSONパースに失敗します。情報・デバッグ・エラーログはUTF-8でstderrへ出してください。
server/discoverがエラーなら必ずinitializeへ戻しますか?
いいえ。UnsupportedProtocolVersionErrorのような認識可能なmodernエラーなら、サーバーが示した対応版から相互対応版を選び、modern方式を続けます。stdioでそれ以外のエラーまたは妥当な時間内に無応答の場合だけ、dual-eraクライアントはlegacy initializeへのfallbackを検討します。
まとめ
stdio型MCPサーバーは、LLMクライアントを外してJSON-RPCを直接流すと、障害の層が見えるようになります。一行一メッセージ、stdoutはMCP専用、ログはstderr、idは応答と照合、終了はstdinを閉じる、というtransportの条件から確認します。
対象が2026-07-28なら、server/discover と各リクエストの必須 _meta を使います。initialize は旧2025-11-25以前の手順です。世代を明記してテストを分ければ、旧記事のコードが動かない理由と、現行サーバーの実装不備を混同せずに診断できます。
一次情報・参考リンク
- MCP 2026-07-28 stdio Transport https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio 公開
- MCP 2026-07-28 Discovery https://modelcontextprotocol.io/specification/2026-07-28/server/discover 公開
- MCP 2026-07-28 Versioning and Compatibility https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning 公開
- MCP 2026-07-28 Base Protocol https://modelcontextprotocol.io/specification/2026-07-28/basic 公開
- JSON-RPC 2.0 Specification https://www.jsonrpc.org/specification
- Node.js Child process https://nodejs.org/api/child_process.html
- Node.js Stream https://nodejs.org/api/stream.html
関連して読む
- · 参考リンク 2件
MCPサーバーが繋がらない時のチェックリスト — claude mcp add・stdio・HTTP・環境変数
MCPサーバーが繋がらない原因の多くはコマンドパス・作業ディレクトリ・環境変数・トランスポート種別の取り違えです。stdioとHTTPの違いから切り分け手順までまとめます。
- · 参考リンク 5件
MCPのstdioとStreamable HTTPはどう選ぶ?2026年版の判断基準
MCP 2026-07-28仕様を基準に、stdioとStreamable HTTPの違い、互換性、安全性、運用コストを判断表と導入手順で整理します。
- · 参考リンク 5件
その条文は「現在」のものです|e-Gov法令API v2の時点指定(asof)と通称検索を実測
e-Gov法令API v2はasofパラメータで過去時点の条文を返し、法令名検索が通称(abbrev)にもマッチします。v1しか叩いていないegov-law-mcp 0.1.0では何が引けないのかを、下請法の改題を題材に実測ログで確認します。