はじめに
MCPを支える技術要素を、SDKを使わない生実装と実測ログで深掘りするシリーズの第2回です。
- 第1回: MCPサーバは「SDKなし100行」で作れる — Claude Codeとの通信を盗聴して中身を全部見てみた
- 第2回(本記事): Streamable HTTP — 同じサーバをHTTPトランスポートに載せ替えてcurlで観察する
- 第3回: OAuth 2.1 — リモートMCPの認可フローを動かす(OAuth 2.0との差分つき)
前回、stdioトランスポートのMCPサーバを依存ゼロの100行で作り、「MCPの正体はJSON-RPC 2.0を話すプロセス」だと確認しました。今回はそのサーバを、リモート接続用のトランスポートである Streamable HTTP に載せ替えます。
本記事のゴールは次の2つです。
- プロトコル(JSON-RPC + MCPメソッド群)とトランスポートは別の層である — ハンドラのコードを1行も変えずにトランスポートを差し替えて証明する
- 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.mjs — stdioと違い、自分で起動・常駐させます)、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)を、例によって生実装と実測で確認します。