はじめに
MCP(Model Context Protocol)サーバを「使う」記事はたくさんありますが、「中で何が流れているのか」を見た記事は意外と少ないと感じています。このシリーズでは、MCPを支える3つの技術要素を、SDKを使わない生実装と実測ログで深掘りします。
- 第1回(本記事): JSON-RPC 2.0 — MCPサーバをSDKなし・依存ゼロの100行で作る
- 第2回: Streamable HTTP — 同じサーバをHTTPトランスポートに載せ替えてcurlで観察する
- 第3回: OAuth 2.1 — リモートMCPの認可フローを動かす(OAuth 2.0との差分つき)
本記事のゴールは、次の一文を実感を持って言えるようになることです。
stdioトランスポートのMCPサーバとは、stdinから改行区切りのJSONを読み、stdoutに書くだけのプロセスである。
検証環境: macOS / Node.js v25.6.1 / Claude Code v2.1.233
MCPの最小限の全体像
手を動かす前に、必要な概念だけ押さえます。
- 3層構造: Host(Claude Codeなどのアプリ)→ Client(接続1本ごとの窓口)→ Server(機能提供側)
- サーバが提供できるもの: Tools(モデルが呼ぶ関数)/ Resources(読み取りデータ)/ Prompts(テンプレート)
- トランスポート: stdio(ローカル・子プロセス型)と Streamable HTTP(リモート型)の2種類
- 中身のプロトコル: どちらのトランスポートでも、流れるメッセージは JSON-RPC 2.0
つまりMCPは「JSON-RPC 2.0 + 決められたメソッド群(initialize、tools/list、tools/callなど)」であり、トランスポートはその運び方にすぎません。本記事ではいちばん単純な運び方であるstdioを使って、プロトコルの中身に集中します。
JSON-RPC 2.0 の要点は「メッセージが3種類」だけ
JSON-RPC 2.0は仕様書が数ページしかない小さなプロトコルで、覚えることは実質これだけです。
| 種類 | 見分け方 | ルール |
|---|---|---|
| request |
id あり + method あり |
受けた側は必ず response を返す |
| notification |
id なし + method あり |
返事をしてはいけない |
| response |
result か error を持つ |
result と error は排他 |
-
idは呼び出し側が採番し、応答との対応付けに使います(順不同で返してよい=非同期と相性が良い) - エラーコードは予約済み:
-32700Parse error /-32601Method not found /-32602Invalid params など
依存ゼロ・100行弱でMCPサーバを書く
npm install は一切不要です。server.mjs として保存してください。
// SDKを一切使わない最小のMCPサーバ (stdioトランスポート)
import { createInterface } from "node:readline";
const PROTOCOL_VERSION = "2025-06-18";
// ---- このサーバが提供するツール定義 ----
const TOOLS = [
{
name: "add",
description: "2つの数値を足し算する",
inputSchema: {
type: "object",
properties: {
a: { type: "number", description: "1つ目の数" },
b: { type: "number", description: "2つ目の数" },
},
required: ["a", "b"],
},
},
];
// ---- JSON-RPC メソッドごとのハンドラ(ディスパッチテーブル) ----
const handlers = {
initialize: (params) => {
log(`initialize: client=${params?.clientInfo?.name} protocol=${params?.protocolVersion}`);
return {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: {} }, // 「toolsを提供できる」という宣言
serverInfo: { name: "json-rpc-study", version: "0.1.0" },
};
},
ping: () => ({}),
"tools/list": () => ({ tools: TOOLS }),
"tools/call": (params) => {
const { name, arguments: args } = params;
if (name !== "add") {
// プロトコルエラーではなく「ツール実行の失敗」は isError で表現する
return { content: [{ type: "text", text: `unknown tool: ${name}` }], isError: true };
}
const sum = args.a + args.b;
return { content: [{ type: "text", text: `${args.a} + ${args.b} = ${sum}` }] };
},
};
// id を持たないメッセージ = notification。返事をしない
const notificationHandlers = {
"notifications/initialized": () => log("client initialized — 通常運転開始"),
};
const PARSE_ERROR = -32700;
const METHOD_NOT_FOUND = -32601;
const INTERNAL_ERROR = -32603;
function send(msg) {
process.stdout.write(JSON.stringify(msg) + "\n");
}
function log(text) {
process.stderr.write(`[server] ${text}\n`);
}
const rl = createInterface({ input: process.stdin });
rl.on("line", (line) => {
if (!line.trim()) return;
let msg;
try {
msg = JSON.parse(line);
} catch {
// パースエラー時は id が特定できないので id: null で返す
send({ jsonrpc: "2.0", id: null, error: { code: PARSE_ERROR, message: "Parse error" } });
return;
}
// notification: id が無い → 処理はするが返事はしない
if (msg.id === undefined) {
notificationHandlers[msg.method]?.(msg.params);
return;
}
// request: id がある → 必ず result か error を返す
const handler = handlers[msg.method];
if (!handler) {
send({
jsonrpc: "2.0",
id: msg.id,
error: { code: METHOD_NOT_FOUND, message: `Method not found: ${msg.method}` },
});
return;
}
try {
send({ jsonrpc: "2.0", id: msg.id, result: handler(msg.params) });
} catch (e) {
send({
jsonrpc: "2.0",
id: msg.id,
error: { code: INTERNAL_ERROR, message: String(e?.message ?? e) },
});
}
});
log("stdio server started — waiting for JSON-RPC messages on stdin");
ポイントを3つだけ。
-
handlersはディスパッチテーブル。JSON-RPCのmethod文字列をそのままキーにして関数を引くので、handlers[msg.method]がundefinedなら即-32601 Method not foundと判定できます -
stdoutに書いてよいのはJSON-RPCメッセージだけ。stdioトランスポートでは、サーバのstdoutはパイプでクライアントプログラムに直結され、クライアントは届いた1行1行をすべてJSONとしてパースします。そこに
console.logでデバッグ文字列を混ぜると、クライアント側でパースエラーになり通信が壊れます。ログは必ずstderrへ(stderrはプロトコルの対象外で、クライアントがログとして拾ってくれます)。このルールは仕様の Transports — stdio 節に明記されています -
エラーには層が2つある。存在しないメソッド → JSON-RPCの
error(プロトコル層)。ツール実行の失敗 →resultの中のisError: true(アプリケーション層)
人間がMCPクライアントを演じてみる
このサーバの面白いところは、キーボードで直接話しかけられることです。
$ node server.mjs
[server] stdio server started — waiting for JSON-RPC messages on stdin
ここに1行打ってリターンすると、即座に応答が返ります。実際のクライアントが接続時に行うシーケンスを手で再現してみます。
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"manual-test","version":"0.0.1"}}}
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"json-rpc-study","version":"0.1.0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
(応答なし — notificationだから)
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"add","arguments":{"a":19,"b":23}}}
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"19 + 23 = 42"}]}}
エラー系も見ておきます。
{"jsonrpc":"2.0","id":4,"method":"no/such/method"}
{"jsonrpc":"2.0","id":4,"error":{"code":-32601,"message":"Method not found: no/such/method"}}
this is not json
{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}
3種類のメッセージ、ライフサイクル、2つのエラーコード — JSON-RPCの主要素がすべて手入力で確認できました。終了は Ctrl+D です。
Claude Codeに接続する
本物のクライアントに繋ぎます。登録は1コマンドです。
claude mcp add json-rpc-study -- node /絶対パス/server.mjs
claude を起動して「addツールで100と111を足して」と頼むと、確認プロンプトのあとに 211 が返ってきます。この値は自作サーバの tools/call ハンドラが組み立てた文字列そのものです。
ハマりどころ: 確認プロンプトが出ずに拒否される
筆者の環境では、ツールを頼んでも確認プロンプトが表示されないまま「呼び出しが拒否されました」と返される現象に遭遇しました。原因はClaude Codeの権限モードが don't askモード(確認プロンプトを出さない代わりに、未許可のツールを自動で拒否する)になっていたことです。Shift+Tabでdefaultモードに戻したところ、初回呼び出し時に確認プロンプトが表示され、許可すると実行されました。
サーバの接続自体は claude mcp list で ✔ Connected と確認できていたので、これはプロトコルの問題ではなくクライアント側の安全装置です。「MCPサーバが動かない」ように見えても、サーバ実装とクライアント権限は別の層の話、という切り分けを覚えておくと役立ちます。
なお、stdioサーバは事前起動が不要です。登録するのは「起動コマンド」であり、Claude Codeがセッション開始時に子プロセスとして起動し、終了時に片付けます。デーモン化もポート確保も要りません。
本物のトラフィックを盗聴する
ここからが本記事の本番です。Claude Codeが実際に何を送っているのか、推測ではなく実物を見ます。
方法は簡単で、クライアントとサーバの間に「中継しながら記録するプロキシ」を挟みます。wrap.mjs:
// server.mjs への中継ラッパー: 両方向の生トラフィックを wire.log に記録する
import { spawn } from "node:child_process";
import { appendFileSync } from "node:fs";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const dir = dirname(fileURLToPath(import.meta.url));
const logPath = join(dir, "wire.log");
const log = (prefix, line) =>
appendFileSync(logPath, `${new Date().toISOString()} ${prefix} ${line}\n`);
const child = spawn("node", [join(dir, "server.mjs")], {
stdio: ["pipe", "pipe", "inherit"],
});
// クライアント → サーバ方向
createInterface({ input: process.stdin }).on("line", (line) => {
log("→", line);
child.stdin.write(line + "\n");
});
process.stdin.on("end", () => child.stdin.end());
// サーバ → クライアント方向
createInterface({ input: child.stdout }).on("line", (line) => {
log("←", line);
process.stdout.write(line + "\n");
});
child.on("exit", (code) => process.exit(code ?? 0));
登録先をこのラッパーに差し替えれば、server.mjsは無改造のまま全トラフィックが wire.log に残ります。この手法はSDK製や他人製のMCPサーバのデバッグにもそのまま使えます。
claude mcp remove json-rpc-study
claude mcp add json-rpc-study -- node /絶対パス/wrap.mjs
実測ログ(Claude Code v2.1.233)
セッションを起動し、addツールを呼んでもらったときの実録です(読みやすさのため一部整形)。
04:59:32.307 → {"method":"initialize","params":{"protocolVersion":"2025-11-25",
"capabilities":{"roots":{"listChanged":true},"elicitation":{}},
"clientInfo":{"name":"claude-code","title":"Claude Code","version":"2.1.233",...}},
"jsonrpc":"2.0","id":0}
04:59:32.340 ← {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18",
"capabilities":{"tools":{}},"serverInfo":{"name":"json-rpc-study","version":"0.1.0"}}}
04:59:32.353 → {"method":"notifications/initialized","jsonrpc":"2.0"}
04:59:32.353 → {"method":"tools/list","jsonrpc":"2.0","id":1}
04:59:32.357 ← {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"add",...}]}}
(10分後、ユーザーが「addツールで足して」と依頼)
05:09:22.877 → {"method":"tools/call","params":{"name":"add","arguments":{"a":1000,"b":2222},
"_meta":{"claudecode/toolUseId":"toolu_018Epm...","progressToken":2}},
"jsonrpc":"2.0","id":2}
05:09:22.879 ← {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"1000 + 2222 = 3222"}]}}
実測して初めて分かったこと
1. バージョンネゴシエーションの実演
Claude Codeは仕様最新版の 2025-11-25 を提示してきましたが、サーバが 2025-06-18 を返すと、切断せずそのまま続行しました。「クライアントが提示 → サーバが対応版で応答 → 合意」というネゴシエーションのルールが目の前で動いています。MCPのプロトコルバージョンは YYYY-MM-DD 形式の仕様リビジョン日付で、セマンティックバージョンではありません。
2. capabilityは双方向 — クライアントのcapabilityは「サーバから逆向きに呼ばれる機能」
クライアント側も roots と elicitation というcapabilityを宣言してきました。MCPの機能の多くはオプションで、initialize時に互いに「対応している機能」を申告し合い、相手が申告していない機能は使ってはいけないというルールで動きます。
ここで重要な前提として、capabilitiesに書けるキーは自由ではなく、仕様で語彙が決まっています(2025-06-18版時点。定義は仕様の Lifecycle — Capability Negotiation 節)。
-
クライアントが申告できるもの:
roots/sampling/elicitation -
サーバが申告できるもの:
tools/resources/prompts/logging/completions(いずれも仕様の Server Features 配下に定義) - 標準外の拡張は
experimentalキーの下に隔離する決まり
値の読み方にもルールがあります。キーが存在する = 対応、キーが無い = 非対応(相手はその機能を使ってはいけない)、そして空オブジェクト {} は「対応するが追加オプションはない」という意味です。roots の中の "listChanged": true のようなフラグ(一覧の変更を通知するか等)も仕様で定義されています。
そして面白いのは、クライアントが申告するcapabilityはサーバ→クライアント方向のリクエストへの対応可否だという点です。
| capability | 申告者 | リクエストの向き | 内容 |
|---|---|---|---|
tools |
サーバ | クライアント → サーバ | ツール実行(tools/call) |
roots |
クライアント | サーバ → クライアント | サーバが対象ディレクトリを質問できる(roots/list) |
elicitation |
クライアント | サーバ → クライアント | サーバがユーザーへの追加質問を依頼できる(elicitation/create) |
sampling |
クライアント | サーバ → クライアント | サーバがクライアント側のLLMに推論を依頼できる |
たとえばelicitationは、デプロイ系ツールが実行途中で「本番環境でよいですか?」とユーザーに確認したいときに使います。サーバはユーザーと直接会話できないので、クライアントに仲介を依頼するわけです。
これが成立するのは、JSON-RPC 2.0が対称なプロトコルだからです。stdin/stdoutというチャネルの向きは固定ですが、その上を流れるrequestは双方が送れます。
この前提を踏まえると、実測ログの "capabilities":{"roots":{"listChanged":true},"elicitation":{}} から「Claude Codeは仕様上の3つのクライアントcapabilityのうち sampling だけ申告していない」と読み取れます。キーの不在は非対応の意思表示なので、サーバは「このクライアントにsamplingは使えない」と判断できる — 申告が無いこと自体が情報になるわけです。
3. ツールの発見は「使うとき」ではなく「セッション開始時」
initialize完了の数ms後に tools/list が飛んでいます。一方、実際にツールが使われた tools/call はその10分後です。この時間差から、Claude Codeの動き方が読み取れます。
-
セッション起動時:
tools/listでツール定義(名前・説明・inputSchema)を取得し、手元に保持 - ユーザーが「addツールで足して」と入力したとき: その文章と保持済みのツール定義一覧をセットでLLMに送る。LLMは一覧の中から「addが使える」と判断してtool_useを返す
- このタイミングでMCPサーバに飛ぶのは
tools/callだけ(「何ができますか?」と聞き直したりはしない)
ここで「MCPサーバのツールを把握しているのはクライアントなのに、なぜLLMがツールを選べるのか?」という疑問が湧きます。役割分担はこうなっています。
(1) クライアント → LLM API: ユーザーの文章全文 + ツール定義一覧(tools/listの結果を変換して毎回同梱)
(2) LLM → クライアント: 「このツールをこの引数で使いたい」という構造化ブロック(tool_use)を生成
(3) クライアント → MCPサーバ: tool_useをJSON-RPCの tools/call に翻訳して送信
(4) クライアント → LLM API: ツールの実行結果を返す
(5) LLM: 結果を踏まえた自然言語の回答を生成(「結果は211です」)
ポイントは、LLMはMCPの存在を知らないことです。LLMが見るのは(1)で同梱された一覧だけで、ユーザーの意図とツールの description を照らして「使いたい」と意思表示する(2)まで。実際にJSON-RPCを話すのは常にクライアントです。LLMは口だけで手を持たない — だからこそ(2)と(3)の間にクライアントが権限確認を挟めます。
この2層の接続は実測ログにも写っています。tools/call の _meta にあった claudecode/toolUseId の値 toolu_... は、(2)でLLMが出力したtool_useブロックのIDそのものです。
裏を返せば、ツール定義の description はLLMがツールを選ぶ判断材料そのものです。server.mjsに書いた「2つの数値を足し算する」という一文があるからこそ、LLMは「足して」という依頼にaddツールを選べます。
4. _meta フィールドの存在
手入力では送らなかった params._meta が付いてきました。
-
progressToken: サーバが長時間処理の進捗をnotifications/progressで返すためのトークン。サーバ側の対応は任意で、無視しても正常動作します -
claudecode/toolUseId:ベンダー名/キー形式の名前空間付きメタデータ。値のtoolu_...はAnthropic APIでtool_useブロックに振られるIDそのもので、LLM APIレイヤーとMCPレイヤーが接続される瞬間が記録されています
5. プロセスは会話の間ずっと生きている
initializeの10分後の tools/call が、同じ接続の続き(id=2、0と1の続番)として届きました。stdioサーバは「呼び出しごとに起動」ではなく「セッション中ずっと待機」です。逆に、セッションを起動し直すと新プロセスでinitializeからやり直しになるため、セッションをまたぐ状態をサーバに持たせてはいけないことも分かります。
6. 応答レイテンシは2ms
プロセス間パイプの速さです。次回のStreamable HTTPと比較する際の基準値になります。
補足: 「MCPクライアント」の実装とは結局何なのか
ここまでの観察を裏返すと、Claude CodeやClaude Desktopに組み込まれている「MCPクライアント機能」の正体が見えてきます。実装すべきことは、本記事でサーバ側から観察したメッセージ交換の鏡像で、突き詰めれば次の4つです。
- 子プロセスの起動とstdin/stdoutのパイプ接続(またはHTTP接続)
- initialize → initialized のハンドシェイクとcapability交換
-
tools/listでツール定義を取得し、LLM APIのtoolsパラメータ形式に変換 - LLMのtool_use出力を
tools/callのJSON-RPCに翻訳し、結果をtool_resultとして返すループ
独自のAIエージェントでMCPを使いたい場合は、この4つを実装することになります。実務ではAWS Strands AgentsやClaude Agent SDKなど、MCPクライアントを組み込みで持つフレームワークに任せるのが普通ですし、Claude APIには接続自体をAnthropic側が代行するMCPコネクタという機能もあります。ただ、どれを使っても内部で起きているのは本記事のwire.logで見たあのメッセージ交換です。フレームワークのMCP接続が不調のとき、「initializeで止まっているのか、tools/listが返らないのか、それとも権限層か」を切り分けられるのが、生で一度見ておくことの価値だと思います。
まとめ
- MCPサーバの正体は「JSON-RPC 2.0を話すプロセス」。依存ゼロの100行で書けて、どのMCPクライアントからも接続できる
- JSON-RPCの要点は request / notification / response の3種類だけ。notificationには返事をしない
- エラーはプロトコル層(JSON-RPCの
error)とアプリ層(isError: true)の2層構造 - stdioサーバのライフサイクルはクライアントが管理する。事前起動・常駐・ポート確保は不要
- 中継プロキシ(wrap.mjs)を挟めば、どんなMCPサーバでも生トラフィックを観察できる
次回: 今回作った handlers オブジェクトをそのまま再利用し、トランスポートだけをStreamable HTTPに載せ替えます。同じJSON-RPCメッセージがHTTP POSTとSSEストリームに乗って流れる様子、Mcp-Session-Id によるセッション管理、MCP-Protocol-Version ヘッダの役割をcurlで観察します。「プロトコルとトランスポートは別の層」であることが、コードの差分として見えるはずです。