2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MCPサーバは「SDKなし100行」で作れる — Claude Codeとの通信を盗聴して中身を全部見てみた【MCP深掘りシリーズ 1】

2
Last updated at Posted at 2026-08-17

はじめに

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 + 決められたメソッド群(initializetools/listtools/callなど)」であり、トランスポートはその運び方にすぎません。本記事ではいちばん単純な運び方であるstdioを使って、プロトコルの中身に集中します。

JSON-RPC 2.0 の要点は「メッセージが3種類」だけ

JSON-RPC 2.0は仕様書が数ページしかない小さなプロトコルで、覚えることは実質これだけです。

種類 見分け方 ルール
request id あり + method あり 受けた側は必ず response を返す
notification id なし + method あり 返事をしてはいけない
response resulterror を持つ resulterror は排他
  • id は呼び出し側が採番し、応答との対応付けに使います(順不同で返してよい=非同期と相性が良い)
  • エラーコードは予約済み: -32700 Parse error / -32601 Method not found / -32602 Invalid 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つだけ。

  1. handlers はディスパッチテーブル。JSON-RPCの method 文字列をそのままキーにして関数を引くので、handlers[msg.method]undefined なら即 -32601 Method not found と判定できます
  2. stdoutに書いてよいのはJSON-RPCメッセージだけ。stdioトランスポートでは、サーバのstdoutはパイプでクライアントプログラムに直結され、クライアントは届いた1行1行をすべてJSONとしてパースします。そこに console.log でデバッグ文字列を混ぜると、クライアント側でパースエラーになり通信が壊れます。ログは必ずstderrへ(stderrはプロトコルの対象外で、クライアントがログとして拾ってくれます)。このルールは仕様の Transports — stdio 節に明記されています
  3. エラーには層が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は「サーバから逆向きに呼ばれる機能」

クライアント側も rootselicitation という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の動き方が読み取れます。

  1. セッション起動時: tools/list でツール定義(名前・説明・inputSchema)を取得し、手元に保持
  2. ユーザーが「addツールで足して」と入力したとき: その文章と保持済みのツール定義一覧をセットでLLMに送る。LLMは一覧の中から「addが使える」と判断してtool_useを返す
  3. このタイミングで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つです。

  1. 子プロセスの起動とstdin/stdoutのパイプ接続(またはHTTP接続)
  2. initialize → initialized のハンドシェイクとcapability交換
  3. tools/list でツール定義を取得し、LLM APIの tools パラメータ形式に変換
  4. 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で観察します。「プロトコルとトランスポートは別の層」であることが、コードの差分として見えるはずです。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?