0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SDKなしでMCPのStreamable HTTPサーバを作って、仕組みを理解する【MCP深掘りシリーズ 2】

0
Last updated at Posted at 2026-08-18

はじめに

MCPを支える技術要素を、SDKを使わない生実装と実測ログで深掘りするシリーズの第2回です。

前回、stdioトランスポートのMCPサーバを依存ゼロの100行で作り、「MCPの正体はJSON-RPC 2.0を話すプロセス」だと確認しました。今回はそのサーバを、リモート接続用のトランスポートである Streamable HTTP に載せ替えます。

本記事のゴールは次の2つです。

  1. プロトコル(JSON-RPC + MCPメソッド群)とトランスポートは別の層である — ハンドラのコードを1行も変えずにトランスポートを差し替えて証明する
  2. Streamable HTTPの正体は「基本はただのHTTP POST。必要なときだけ応答がSSEに格上げされる」 — curlと実クライアントの盗聴ログで確認する

検証環境: macOS / Node.js v25.6.1 / Claude Code v2.1.233 / MCP仕様 2025-06-18

Streamable HTTP の要点

仕様の Transports — Streamable HTTP 節が定める仕組みを、stdioとの対応で整理します。

項目 stdio での姿 Streamable HTTP での姿
クライアント→サーバの送信 stdin に1行書く POST /mcp のボディにJSON-RPCを1件入れる
サーバからの応答 stdout から1行読む POSTのレスポンスボディ(JSON即答 or SSEストリーム)
notification への反応 「返事をしない」 202 Accepted(ボディなし) が返る
セッションの単位 接続 = プロセスの生存期間 接続は1リクエストごと。継続性は Mcp-Session-Id ヘッダ で作る
サーバの起動と管理 クライアントがプロセスを起動・終了 サーバは自分で起動・常駐。クライアントはURLに接続しに来る
サーバ→クライアントの送信 stdoutにいつでも書ける クライアントが GET /mcp でSSEストリームを開いておき、そこに流す

エンドポイントは1つだけ(本記事では /mcp)で、HTTPメソッドで役割を分けます。

  • POST: クライアント→サーバのJSON-RPC送信。応答は application/json(即答)か text/event-stream(ストリーム)をサーバが選ぶ
  • GET: サーバ→クライアント方向のSSEストリームを開設
  • DELETE: セッションの明示的終了

なぜWebSocketではないのか

「双方向ならWebSocketでは?」と思うところですが、Streamable HTTPの設計はこう読めます。

まず、単純なHTTP POST往復(リクエスト → 完成したJSONを1個返す)には構造的な限界があります。たとえばツールの実行に60秒かかるとき、HTTPの基本形は「1リクエストに1レスポンス、返したら取引終了」なので、実行の途中に「いま30%です」という中間メッセージを差し込む場所が、構造上どこにも存在しません。クライアントは60秒の完全な沈黙の中で「処理中なのか、サーバに障害が起きたのか」を区別できず、経路上のプロキシやLBは無通信の接続をタイムアウトで切りがちです。

かといってWebSocketを採ると、LB・プロキシ・認証ミドルウェアなどインフラ全体のWebSocket対応が前提になります。

そこで 「基本はただのHTTP、必要なときだけ応答をSSEに格上げ」 というハイブリッド。既存のHTTPインフラ(認証ヘッダ、ロードバランサ、サーバレス)にそのまま乗れて、進捗が必要な処理だけストリームにできます。

サーバレスと相性が良い理由

仕様上、セッションIDの発行は任意です(完全ステートレス運用が可能)。これがサーバレス環境で効いてきます。

サーバレス(AWS Lambda等)は「リクエストごとにどのインスタンスに当たるか分からない・インスタンスはいつ消えるか分からない」という実行モデルです。本記事の実装にある const sessions = new Map() のようなメモリ上のセッション管理は、initializeを処理したインスタンスと次のtools/callを処理するインスタンスが別になった時点で破綻します(後述する「サーバ再起動→404」が平常運転で起きるのと同じ)。回避するにはセッションをDynamoDB等へ外出しする必要があり、コストと手間が増えます。加えて、GET /mcp の「終わらないレスポンス」はLambdaの実行時間上限とも根本的に相性が悪い。

そこでMCPの仕様は、状態を持つ機能をすべてオプションにしてあります。

  • セッションID発行は任意 → 発行しなければ全リクエストが自己完結し、どのインスタンスが受けても処理できる
  • GET /mcp のサポートは任意 → 未対応なら405を返してよい
  • SSE格上げも任意 → 全部JSON即答でも仕様違反ではない

フル機能ならステートフルに、サーバレスなら「POSTのJSON往復だけ」のミニマム構成に — 同じ仕様のままインフラに合わせて削れる設計になっています。

旧SSEトランスポートとの違い — なぜ置き換えられたか

旧仕様(2024-11-05版)の HTTP with SSEトランスポート は、2エンドポイント構成でした。

① クライアント: GET /sse でSSEストリームを開く(常時接続)
② サーバ: SSEで「POST先のURL」(セッション識別子入り)を通知
③ クライアント: POST /messages?session=xxx に JSON-RPC を送る
④ サーバ: POSTへのHTTP応答は 202 Accepted のみ(中身は空)
⑤ サーバ: tools/call の結果は……①のSSEストリーム側に流れてくる!

決定的なのは⑤で、リクエストへの応答が、そのリクエストのHTTPレスポンスでは返ってこない構造でした。応答を届けるには「このセッションのSSEストリームを握っているのは誰か」という状態が必須で、ステートレス化は原理的に不可能。複数台構成ではPOSTを受けた インスタンスとSSEを握るインスタンスをつなぐ共有基盤まで必要になります。ストリームが切れた瞬間に飛んでいた応答は失われるため、再接続にも弱い。

Streamable HTTPはここを反転させ、応答はPOSTのレスポンスとして返す、つまり普通のHTTPに戻しました。これで「1つのPOSTを受けて、応答を返して、忘れる」という自己完結処理が可能になり、常時接続もセッションも「あれば嬉しいオプション」に格下げされました。2025-03-26版のchangelog にも "Replaced the previous HTTP+SSE transport with a more flexible Streamable HTTP transport" と明記されています。

補足すると、「Streamable HTTP」という名前とルールセット自体はMCPが作ったMCP固有の仕様です(初出は上記changelogが参照する PR #206。設計の経緯・WebSocketを採らなかった理由の議論もここで読めます)。ただし部品はすべてAI以前からある枯れたWeb技術 — HTTP/1.1のchunked転送、HTML5時代に標準化されたSSE — で、新しいワイヤ技術は何も発明されていません。ChatGPTやClaudeのAPIがトークンを逐次表示する仕組みもSSEであり、「LLMの『生成しながら届ける』性質とSSEの相性の良さ」は業界で実証済みでした。

実装: ハンドラは前回と同一、変わるのは運び方だけ

前回のserver.mjsのハンドラ群(handlers / notificationHandlers)は一切変更せず、その周りだけを書き換えます。トランスポート層の中心はこのPOSTハンドラです。

async function handlePost(req, res) {
  const body = await readBody(req);
  let msg;
  try {
    msg = JSON.parse(body);
  } catch {
    return sendJson(res, 400, rpcError(null, PARSE_ERROR, "Parse error"));
  }

  // initialize だけはセッション不要。ここでセッションを発行する
  if (msg.method === "initialize") {
    const sessionId = randomUUID();
    sessions.set(sessionId, { sseStreams: new Set() });
    return sendJson(
      res, 200,
      { jsonrpc: "2.0", id: msg.id, result: handlers.initialize(msg.params) },
      { "Mcp-Session-Id": sessionId },   // ← セッションIDはHTTPヘッダで発行
    );
  }

  // initialize 以外は Mcp-Session-Id ヘッダ必須
  const sid = req.headers["mcp-session-id"];
  if (!sid) return sendJson(res, 400, rpcError(msg.id, -32000, "Missing Mcp-Session-Id header"));
  if (!sessions.has(sid)) {
    // 404 を受けたクライアントは initialize からやり直す決まり
    return sendJson(res, 404, rpcError(msg.id, -32001, "Unknown or expired session"));
  }

  // notification: stdio版の「返事をしない」は、HTTPでは「202 Accepted・ボディなし」になる
  if (msg.id === undefined) {
    notificationHandlers[msg.method]?.(msg.params);
    res.writeHead(202);
    return res.end();
  }

  // 時間のかかるツール(slow_add)で、クライアントがSSEを受け入れるなら、応答をSSEに格上げ
  const acceptsSse = (req.headers.accept ?? "").includes("text/event-stream");
  if (msg.method === "tools/call" && msg.params?.name === "slow_add" && acceptsSse) {
    return streamSlowAdd(res, msg);
  }

  // それ以外は普通のJSONで即答 (前回のディスパッチと同じ流れ)
  const handler = handlers[msg.method];
  if (!handler) return sendJson(res, 200, rpcError(msg.id, METHOD_NOT_FOUND, `Method not found: ${msg.method}`));
  sendJson(res, 200, { jsonrpc: "2.0", id: msg.id, result: handler(msg.params) });
}

SSEに格上げする側はこうなっています。デモ用に、進捗通知を3回送ってから結果を返す slow_add ツールを追加しました。

// slow_add: レスポンスをSSEストリームにして、途中経過(notification)を挟んでから結果を返す
async function streamSlowAdd(res, msg) {
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
  const token = msg.params?._meta?.progressToken;
  for (let i = 1; i <= 3; i++) {
    await sleep(500);
    if (token !== undefined) {
      sseSend(res, {
        jsonrpc: "2.0",
        method: "notifications/progress",
        params: { progressToken: token, progress: i, total: 3 },
      });
    }
  }
  const { a, b } = msg.params.arguments;
  sseSend(res, {
    jsonrpc: "2.0",
    id: msg.id,
    result: { content: [{ type: "text", text: `${a} + ${b} = ${a + b} (3段階かけて計算)` }] },
  });
  res.end(); // request への応答を送り終えたらストリームは閉じてよい
}

// SSEのイベント形式: "event: message" + "data: <JSON>" + 空行
function sseSend(res, obj) {
  res.write(`event: message\ndata: ${JSON.stringify(obj)}\n\n`);
}

ここで注目してほしいのは res.write です。SSEは「HTTPレスポンスを何度も送る」のではなく、1つのHTTPレスポンスのボディを、完成を待たずに断片ごとに送る仕組みです(HTTP/1.1のchunked転送)。writeHead でヘッダだけ先に送り、res.write でボディを書き足し、res.end() でようやく完結します。SSEの data: ... \n\n(空行区切り)は、そのストリームの中でメッセージ境界を示す書式で、stdioにおける改行と同じ役割です。data: の中身は前回stdioで見たのと同じJSON-RPCです。

GET(サーバ→クライアント方向)とDELETE(セッション終了)を含む全体は約280行です。

server.mjs 全文(盗聴ログ機能つき・約280行)
// Streamable HTTP トランスポートの生実装 (SDKなし・依存ゼロ)
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
import { appendFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const PROTOCOL_VERSION = "2025-06-18";
const PORT = Number(process.env.PORT ?? 3333);

// ---- 通信ログ: 全トラフィックを wire.log に記録 ----
const WIRE_LOG = join(dirname(fileURLToPath(import.meta.url)), "wire.log");
function wire(prefix, text) {
  appendFileSync(WIRE_LOG, `${new Date().toISOString()} ${prefix} ${text}\n`);
}

// sessionId → { sseStreams: Set<res> }
const sessions = new Map();

const TOOLS = [
  {
    name: "add",
    description: "2つの数値を足し算する",
    inputSchema: {
      type: "object",
      properties: {
        a: { type: "number", description: "1つ目の数" },
        b: { type: "number", description: "2つ目の数" },
      },
      required: ["a", "b"],
    },
  },
  {
    name: "slow_add",
    description: "進捗通知を3回送りながらゆっくり足し算する (SSEの意義を見るデモ用)",
    inputSchema: {
      type: "object",
      properties: { a: { type: "number" }, b: { type: "number" } },
      required: ["a", "b"],
    },
  },
];

// ---- JSON-RPC ハンドラ群: 第1回のstdio版と同一 (transport 非依存) ----
const handlers = {
  initialize: (params) => {
    log(`initialize: client=${params?.clientInfo?.name} protocol=${params?.protocolVersion}`);
    return {
      protocolVersion: PROTOCOL_VERSION,
      capabilities: { tools: {}, logging: {} },
      serverInfo: { name: "streamable-http-study", version: "0.1.0" },
    };
  },

  ping: () => ({}),

  "tools/list": () => ({ tools: TOOLS }),

  "tools/call": (params) => {
    const { name, arguments: args } = params;
    if (name === "add" || name === "slow_add") {
      const sum = args.a + args.b;
      return { content: [{ type: "text", text: `${args.a} + ${args.b} = ${sum}` }] };
    }
    return { content: [{ type: "text", text: `unknown tool: ${name}` }], isError: true };
  },
};

const notificationHandlers = {
  "notifications/initialized": () => log("client initialized — 通常運転開始"),
};

const PARSE_ERROR = -32700;
const METHOD_NOT_FOUND = -32601;
const INTERNAL_ERROR = -32603;

function log(text) {
  process.stderr.write(`[server] ${text}\n`);
}

function sendJson(res, status, obj, extraHeaders = {}) {
  wire("", `HTTP ${status} ${Object.keys(extraHeaders).length ? JSON.stringify(extraHeaders) + " " : ""}${JSON.stringify(obj)}`);
  res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders });
  res.end(JSON.stringify(obj));
}

function rpcError(id, code, message) {
  return { jsonrpc: "2.0", id: id ?? null, error: { code, message } };
}

// SSEのイベント形式: "event: message" + "data: <JSON>" + 空行
function sseSend(res, obj) {
  wire("", `SSE ${JSON.stringify(obj)}`);
  res.write(`event: message\ndata: ${JSON.stringify(obj)}\n\n`);
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function readBody(req) {
  let data = "";
  for await (const chunk of req) data += chunk;
  return data;
}

// slow_add: レスポンスをSSEストリームにして、途中経過(notification)を挟んでから結果を返す
async function streamSlowAdd(res, msg) {
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
  });
  const token = msg.params?._meta?.progressToken;
  const total = 3;
  for (let i = 1; i <= total; i++) {
    await sleep(500);
    if (token !== undefined) {
      sseSend(res, {
        jsonrpc: "2.0",
        method: "notifications/progress",
        params: { progressToken: token, progress: i, total },
      });
    }
  }
  const { a, b } = msg.params.arguments;
  sseSend(res, {
    jsonrpc: "2.0",
    id: msg.id,
    result: { content: [{ type: "text", text: `${a} + ${b} = ${a + b} (3段階かけて計算)` }] },
  });
  res.end(); // request への応答を送り終えたらストリームは閉じてよい
}

async function handlePost(req, res) {
  const body = await readBody(req);
  // conn= はクライアント側TCPポート。どのTCP接続で届いたかを識別できる
  wire("", `POST /mcp conn=${req.socket.remotePort} headers=${JSON.stringify(req.headers)} body=${body}`);
  let msg;
  try {
    msg = JSON.parse(body);
  } catch {
    return sendJson(res, 400, rpcError(null, PARSE_ERROR, "Parse error"));
  }

  // initialize だけはセッション不要。ここでセッションを発行する
  if (msg.method === "initialize") {
    const sessionId = randomUUID();
    sessions.set(sessionId, { sseStreams: new Set() });
    log(`session created: ${sessionId}`);
    return sendJson(
      res,
      200,
      { jsonrpc: "2.0", id: msg.id, result: handlers.initialize(msg.params) },
      { "Mcp-Session-Id": sessionId },
    );
  }

  // initialize 以外は Mcp-Session-Id ヘッダ必須
  const sid = req.headers["mcp-session-id"];
  if (!sid) {
    return sendJson(res, 400, rpcError(msg.id, -32000, "Missing Mcp-Session-Id header"));
  }
  if (!sessions.has(sid)) {
    // 404 を受けたクライアントは initialize からやり直す決まり
    return sendJson(res, 404, rpcError(msg.id, -32001, "Unknown or expired session"));
  }

  // initialize 後のリクエストは MCP-Protocol-Version ヘッダを名乗る決まり
  const pv = req.headers["mcp-protocol-version"];
  if (pv && pv !== PROTOCOL_VERSION) {
    return sendJson(res, 400, rpcError(msg.id, -32000, `Unsupported protocol version: ${pv}`));
  }

  // notification: stdio版の「返事をしない」は、HTTPでは「202 Accepted・ボディなし」になる
  if (msg.id === undefined) {
    notificationHandlers[msg.method]?.(msg.params);
    wire("", "HTTP 202 (accepted, no body)");
    res.writeHead(202);
    return res.end();
  }

  // slow_add かつクライアントがSSEを受け入れるなら、SSEストリームで応答するデモ
  const acceptsSse = (req.headers.accept ?? "").includes("text/event-stream");
  if (msg.method === "tools/call" && msg.params?.name === "slow_add" && acceptsSse) {
    return streamSlowAdd(res, msg);
  }

  // それ以外は普通のJSONで即答 (フェーズ1のディスパッチと同じ流れ)
  const handler = handlers[msg.method];
  if (!handler) {
    return sendJson(res, 200, rpcError(msg.id, METHOD_NOT_FOUND, `Method not found: ${msg.method}`));
  }
  try {
    sendJson(res, 200, { jsonrpc: "2.0", id: msg.id, result: handler(msg.params) });
  } catch (e) {
    sendJson(res, 200, rpcError(msg.id, INTERNAL_ERROR, String(e?.message ?? e)));
  }
}

// GET /mcp: サーバ→クライアント方向のSSEストリームを開く
function handleGet(req, res) {
  wire("", `GET /mcp conn=${req.socket.remotePort} headers=${JSON.stringify(req.headers)}`);
  const sid = req.headers["mcp-session-id"];
  if (!sid || !sessions.has(sid)) {
    return sendJson(res, sid ? 404 : 400, rpcError(null, -32000, "Session required"));
  }
  if (!(req.headers.accept ?? "").includes("text/event-stream")) {
    return sendJson(res, 406, rpcError(null, -32000, "Accept: text/event-stream required"));
  }

  wire("", "HTTP 200 text/event-stream (サーバ→クライアントのストリームを開いた)");
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
  const session = sessions.get(sid);
  session.sseStreams.add(res);
  log(`SSE stream opened for session ${sid}`);

  // デモ: サーバ都合のタイミングで notification を流す (10秒ごとのログ通知)
  sseSend(res, {
    jsonrpc: "2.0",
    method: "notifications/message",
    params: { level: "info", data: "SSE stream opened — サーバから好きなタイミングで送れます" },
  });
  const timer = setInterval(() => {
    sseSend(res, {
      jsonrpc: "2.0",
      method: "notifications/message",
      params: { level: "info", data: `server time: ${new Date().toISOString()}` },
    });
  }, 10_000);

  req.on("close", () => {
    clearInterval(timer);
    session.sseStreams.delete(res);
    wire("·", `SSE stream closed (session ${sid})`);
    log(`SSE stream closed for session ${sid}`);
  });
}

// DELETE /mcp: クライアントによる明示的なセッション終了
function handleDelete(req, res) {
  wire("", `DELETE /mcp conn=${req.socket.remotePort} headers=${JSON.stringify(req.headers)}`);
  const sid = req.headers["mcp-session-id"];
  if (!sid || !sessions.has(sid)) {
    return sendJson(res, sid ? 404 : 400, rpcError(null, -32000, "Session required"));
  }
  sessions.delete(sid);
  log(`session terminated: ${sid}`);
  wire("", "HTTP 204 (session terminated)");
  res.writeHead(204);
  res.end();
}

const server = createServer((req, res) => {
  const { pathname } = new URL(req.url, `http://localhost:${PORT}`);
  if (pathname !== "/mcp") {
    res.writeHead(404);
    return res.end();
  }
  if (req.method === "POST") return handlePost(req, res);
  if (req.method === "GET") return handleGet(req, res);
  if (req.method === "DELETE") return handleDelete(req, res);
  res.writeHead(405, { Allow: "GET, POST, DELETE" });
  res.end();
});

server.listen(PORT, () => {
  log(`Streamable HTTP server listening on http://localhost:${PORT}/mcp`);
});

curlで観察する

サーバを起動して(node server.mjsstdioと違い、自分で起動・常駐させます)、curlでクライアント役を演じます。

initialize — セッションIDはHTTPヘッダで発行される

$ curl -si -X POST http://localhost:3333/mcp \
    -H 'Content-Type: application/json' \
    -H 'Accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{...}}'

HTTP/1.1 200 OK
Content-Type: application/json
Mcp-Session-Id: 87c94199-21ca-4782-ace2-18534033b551   ← ここでセッション発行

{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18",...}}

以降のリクエストには Mcp-Session-Id ヘッダを付けます。付けないと400、出鱈目な値だと404が返ります(404の意味は後で効いてきます)。

notification — 「返事をしない」のHTTP版

$ curl -si -X POST ... -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
HTTP/1.1 202 Accepted
(ボディなし)

tools/call — 同じメソッドでも応答の運ばれ方が変わる

ここからが本題です。やることはどちらも同じ「2つの数の足し算」 ですが、応答の運ばれ方が変わります。

その1: add ツールで 100 + 111 を計算してもらう — 答え(211)が普通のJSONで即座に返ります。

$ curl -s -X POST ... -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"add","arguments":{"a":100,"b":111}}}'
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"100 + 111 = 211"}]}}

その2: slow_add ツールで 7 + 8 を計算してもらう — 今度は応答自体がSSEストリームになり、「進捗1/3 → 2/3 → 3/3」が0.5秒おきにポツポツ届いてから、最後に答え(15)が来ます(-N はcurlのバッファ無効化)。

$ curl -sN -X POST ... -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"slow_add","arguments":{"a":7,"b":8},"_meta":{"progressToken":99}}}'
event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":99,"progress":1,"total":3}}

event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":99,"progress":2,"total":3}}

event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":99,"progress":3,"total":3}}

event: message
data: {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"7 + 8 = 15 (3段階かけて計算)"}]}}

重要なのは、2つのリクエストの中身はツール名と _meta 以外ほぼ同じで、「SSEで返せ」という指定はどこにもないことです。クライアントは Accept ヘッダで「どちらでも受け取れる」と宣言するだけで、JSONかSSEかを選ぶのはサーバ。即答できるツールはJSON、進捗を届けたいツールはSSE、と使い分けられるのがStreamable HTTPの「Streamable」の意味です。

Claude Code を接続して盗聴する

今回はサーバが自作なので、前回のような中継プロキシは不要です。サーバ自身に全トラフィックを wire.log へ記録する機能を仕込み、実クライアントを接続しました。

claude mcp add --transport http http-study http://localhost:3333/mcp

実測1: ネゴシエーション結果がヘッダに現れる

Claude Codeはinitializeのボディで 2025-11-25(仕様最新版)を提示し、サーバが 2025-06-18 で応答すると、以降の全リクエストの MCP-Protocol-Version ヘッダは合意した側の 2025-06-18 になっていました。

→ POST body={"method":"initialize","params":{"protocolVersion":"2025-11-25",...}}
← HTTP 200 {"Mcp-Session-Id":"740a..."} {"result":{"protocolVersion":"2025-06-18",...}}
→ POST headers={...,"mcp-protocol-version":"2025-06-18","mcp-session-id":"740a..."} body={"method":"notifications/initialized",...}

「ボディで交渉し、結果をヘッダで名乗り続ける」構図です。HTTPはリクエストごとに独立しているので、どの版で合意したかを毎回名乗る必要がある — stdioには無かった、HTTPならではの要素です。

実測2: initialized直後にGETでSSEストリームを開く

initialized送信の約80ms後、Claude CodeはGET /mcpでサーバ→クライアント方向のストリームを開設し、セッション中ずっと維持していました。

08:40:40.243 → POST body={"method":"notifications/initialized","jsonrpc":"2.0"}
08:40:40.248 ← HTTP 202 (accepted, no body)
08:40:40.324 → GET /mcp headers={"accept":"text/event-stream","mcp-session-id":"839f...",...}
08:40:40.328 ← HTTP 200 text/event-stream (サーバ→クライアントのストリームを開いた)
08:40:40.331 ← SSE {"method":"notifications/message","params":{"level":"info","data":"SSE stream opened — サーバから好きなタイミングで送れます"}}
08:40:50.335 ← SSE {"method":"notifications/message","params":{"level":"info","data":"server time: 2026-08-17T08:40:50.335Z"}}

最後の2行に注目してください。クライアントは何もリクエストしていないのに、サーバの都合だけでメッセージが流れています(このデモサーバは10秒ごとにログ通知を送る実装にしてあります)。「サーバから任意のタイミングでpushできる通路」が実際に機能している証拠です。実戦でこのストリームに流れるのは notifications/tools/list_changed(ツール一覧の変更通知)や、サーバ→クライアントのrequest(elicitation等)です。

実測3: 404からの自動復旧(一番の収穫)

サーバを再起動してセッションを消した状態で、Claude Codeに旧セッションIDのままツールを呼ばせてみると:

09:06:23.053  → tools/call (旧セッションID)
09:06:23.058  ← HTTP 404 "Unknown or expired session"
09:06:23.083  → initialize          ← わずか30ms後にやり直し開始
09:06:23.097  → notifications/initialized
09:06:23.110  → GET /mcp            ← SSEストリームも再開
09:06:23.126  → tools/list          ← ツール一覧も取り直し
09:06:23.134  → tools/call          ← 元の呼び出しを自動リトライ
09:06:23.137  ← HTTP 200 (成功)

仕様の「404を受けたクライアントは新しいセッションをinitializeからやり直す」という決まりが、全行程80ms・ユーザーには一切エラーを見せずに実行されました。リトライされたtools/callは、Claude Code固有の _meta.claudecode/toolUseId が元と同一のまま、MCP層の progressToken だけ振り直されており、「LLM層の意図は同じ、MCP層のリクエストは作り直し」というレイヤー分離まで観察できます。

実測4: 「終わらないレスポンス」はTCP接続を専有する

ログに各リクエストのTCP接続(クライアント側ポート番号)を記録してみると:

conn=63672: POST(initialize) → POST(initialized) → GET /mcp   ← ここからこの接続はSSEが専有
conn=63675: POST(tools/list)     ← 以降のPOSTは別の接続へ
conn=63677: POST(tools/call)

HTTP/1.1では1本の接続上のリクエスト→レスポンスは厳密に順番通りで、応答に「どのリクエストへの応答か」というラベルはありません。GETへの終わらないレスポンスが接続を専有するため、以降のPOSTは物理的に別のTCP接続を使うしかない — 「応答の識別子は接続そのもの」というHTTP/1.1の原理が、ポート番号の変化として観察できました。

余談ですが、この「応答の対応付け」問題には3つの解法が今回のログに同居しています: HTTP/1.1は「接続を分ける」、HTTP/2は「ストリームID」(今回は未使用)、JSON-RPCは「メッセージ内の id」。stdioでは接続が1本しかないのでJSON-RPCのidだけが頼りでしたが、トランスポートごとに「どの層が対応付けを担うか」が違うわけです。

実測5: その他の発見

  • DELETEは送ってこない: 放置されたセッションはサーバのメモリに残り続けます。実サーバにはタイムアウトによるセッションGCが必須です(stdioではプロセスが終了すれば状態も消えるので、この問題自体が存在しませんでした)
  • accept-encoding: identity: Claude Codeは圧縮を明示的に拒否していました。gzipは経路上のバッファリングを誘発してSSEの即時性を壊すことがあるため、と読めます
  • レイテンシ: add のサーバ側処理は約1ms。localhostならstdio(前回実測2ms)と差はなく、トランスポートの差はネットワーク越しで初めて効いてきます

まとめ

  • Streamable HTTPは「単一エンドポイントへのHTTP POST + 応答は必要に応じてSSEに格上げ + サーバ→クライアントはGETのSSEストリーム」という構成
  • ハンドラのコードを1行も変えずにstdioから載せ替えられた = プロトコルとトランスポートは別の層
  • セッションは Mcp-Session-Id ヘッダで作る。404を返せばクライアントはinitializeから自動復旧する(実測80ms)
  • 「終わらないレスポンス」がTCP接続を専有するため、GETとPOSTは別接続で並走する
  • 実クライアントはDELETEを送ってくれるとは限らない。セッションGCはサーバの責務

次回: リモートMCPの最後のピース、認可です。今回のサーバは誰でも叩き放題なので、OAuth 2.1で保護します。OAuth 2.0との差分(PKCE必須化・implicit/passwordグラント廃止など)と、MCP固有の仕組み(Protected Resource Metadata / Dynamic Client Registration)を、例によって生実装と実測で確認します。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?