本文へスキップ
Edition · Tokyo

MCPサーバーをJSON-RPCでテストする|stdio最小ハーネス

MCP 2026-07-28のstdioサーバーをLLMクライアントなしで検査するNode.js最小ハーネスです。server/discover、必須_meta、tools/list、改行区切り、stdout汚染を切り分けます。

codeagent.jp編集部 情報確認 約14分
Tags
  • mcp
  • json-rpc
  • stdio
  • testing
  • nodejs
  • troubleshooting
情報確認
参考リンク
7件
更新性
長く使える
読了目安
約14分
更新管理

仕様・料金・提供範囲が変わりやすいテーマは、公開日・更新日・情報確認日を分けて管理します。 導入前には必ず記事末尾の一次情報と公式ドキュメントで最新状況を確認してください。

MCPサーバーをJSON-RPCでテストする|stdio最小ハーネス の16:9共有用サマリー画像。 LLMクライアントを外し、プロセス・フレーミング・MCP応答を順番に検査する 1. まずwireだけを見る: クライアントがサーバーを子プロセスとして起動する、stdinとstdoutは1行1 JSON-RPCメッセージ、ログはstderrへ出しstdoutを汚さない 2. 現行仕様を固定する: server/discoverへ2026-07-28の_metaを付ける、supportedVersionsに対象版があるか確認する、tools/listにも同じ必須_metaを毎回付ける 3. 旧版を混ぜない: initializeは2025-11-25以前のlegacy手順、dual-eraクライアントだけdiscover失敗後にfallbackする、エラーコード-32022はmodernなのでfallbackしない 結論: 2026-07-28はper-request _meta方式。initializeは2025-11-25以前のlegacy専用
MCPサーバーをJSON-RPCでテストする|stdio最小ハーネス 資料 26-16P9 2026.08.13 運用Tips・トラブルシュート

結論

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以降)各リクエストの _metaserver/discover または対象RPC対象
legacy(2025-11-25以前)initialize で交渉initialize → initialized通知対象外、後半に互換メモのみ
dual-eraまずmodern probeで判定stdioでは server/discover判定方針のみ説明

2026-07-28では「接続時に一度だけ共有した情報」を前提にしません。各リクエストに少なくとも次の必須メタデータを入れます。

  • io.modelcontextprotocol/protocolVersion
  • io.modelcontextprotocol/clientCapabilities

io.modelcontextprotocol/clientInfo は必須ではありませんが、公式仕様は通常、毎回含めることを推奨しています。本稿も含めます。

stdioのwireルールを確認する

公式のstdio transport仕様では、クライアントがMCPサーバーを子プロセスとして起動します。サーバーはstdinから読み、stdoutへ書きます。

wire上のチェックポイントは次のとおりです。

  1. 一つのJSON-RPCメッセージを一行にする
  2. メッセージ内に未エスケープの実改行を入れない
  3. クライアントのstdin出力は有効なMCPメッセージだけにする
  4. サーバーのstdout出力は有効なMCPメッセージだけにする
  5. サーバーの通常ログはstderrへ出す
  6. 終了時はまずサーバーの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.resultTypecomplete で、supportedVersionscapabilities を確認します。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オブジェクト、resulterror の同時出現、応答タイムアウトを失敗として扱います。サーバーからの妥当なnotificationはstderrへ表示します。tools/listnextCursor がある場合、この最小版が表示するのは最初の1ページだけだと明示します。完全な一覧が必要なら、同じ _meta と返されたcursorを次の tools/list へ渡して繰り返してください。

終了時はstdinを閉じて2秒待ち、終了しなければ強制終了を試みます。正常なテスト後に非ゼロ終了、シグナル終了、強制終了、または終了待機中のプロトコル違反が発生した場合は、ハーネス自体も失敗終了します。

Windowsで実行する

PowerShellから、テストしたいサーバーの実コマンドと引数を後ろへ並べます。

Terminal window
node .\probe-mcp.mjs node .\dist\server.js

npxで起動するパッケージなら、たとえば次の形です。

Terminal window
node .\probe-mcp.mjs npx -y @scope/example-mcp-server

ハーネスが Discovery を出す前に落ちた場合は、次の順で分類します。

症状主な層確認すること
Failed to start serverプロセス起動command、PATH、作業ディレクトリ
Non-JSON data on server stdoutstdioフレーミングサーバーログをstderrへ移す
Timed out waiting for server/discover世代違いまたは停止対象spec、stdin読取、legacy判定
JSON-RPC error -32602現行メタデータ不備_meta の必須2項目
JSON-RPC error -32022modern版の不一致error.data.supported との共通版
discover成功、toolsなし能力の違いresources/prompts専用か確認
tools/listの形が不正MCP応答resultTypetools

公開前のローカル検証へ組み込むなら、e-Gov法令MCPのnpm公開手順のように、ビルド後の実ファイルを引数へ渡します。ソースを直接実行した結果だけでなく、配布物のentry pointも試してください。

dual-era互換は別レイヤーで実装する

上のコードは2026-07-28を実装したmodernサーバーのテスト専用です。旧サーバーも扱うdual-eraクライアントは、stdio仕様の互換手順に従って server/discover をprobeとして使います。

  1. 優先するmodern版を _meta に入れて server/discover を送る
  2. DiscoverResult が返れば、supportedVersions から相互対応版を選ぶ
  3. -32022 UnsupportedProtocolVersionError など認識可能なmodernエラーなら、data.supported から相互対応版を選ぶ
  4. その他のエラー、または妥当な時間内に無応答ならlegacyと判定する
  5. 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に resulterror の両方がある、または両方ない
  • responseのidが未完了requestに対応しない
  • 成功結果に期待する resultType がない
  • tools/listtools が配列ではない

ツールの outputSchemastructuredContent まで検査する場合は、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/listtools を配列として確認した
  • nextCursor がある場合、先頭ページだけの検査だと明示した
  • stdin書き込みエラーと異常終了を失敗として扱った
  • 終了時はまずstdinを閉じた
  • legacy互換テストをmodern専用テストから分離した
  • -32022initialize へ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以前の手順です。世代を明記してテストを分ければ、旧記事のコードが動かない理由と、現行サーバーの実装不備を混同せずに診断できます。

Primary sources

一次情報・参考リンク

About the author
codeagent.jp編集部

Claude Code / Codex / MCP を個人開発サイト運用と公開MCPサーバー開発で試し、一次情報・検証ログ・失敗例をもとに整理します。

関連して読む