MCP 仕様の 2026-07-28 リリース候補に、Mcp-Session-Id ヘッダーの廃止が入りました。差分としては一行に見えて、リモート MCP サーバーの土台に触れる変更です。
私自身、個人開発の自動化まわりで小さな MCP サーバーを1台だけ動かしておりまして、最初は遠い話のつもりで読んでいました。ところが手元のコードを開いて数えてみると、セッションIDを鍵にした Map が三箇所。そのうち一つは、ページングのオフセットを抱えていました。
負荷分散の背後に2台目を置いた瞬間に崩れる構造。しかも1台で動かしている限り、テストは全部通ります。
以下は、その依存を数え、署名付きカーソルへ移すまでの手元の記録です。仕様はリリース候補の段階ですので、実際の移行前にMCP 仕様の一次情報を必ずご確認ください。
半分だけ失敗する、という壊れ方
先に、壊れ方を目で見える形にしておきたいと思いました。依存パッケージなしの Node で、最小の再現を書きます。
同じデータ源を見る「インスタンス」を複数用意し、クライアントはラウンドロビンで呼びます。セッション常駐型は、オフセットを自分のメモリに持ちます。
// roundrobin-repro.mjs — node roundrobin-repro.mjs で実行
import crypto from "node:crypto";
const ITEMS = Array.from({ length: 500 }, (_, i) => `item-${i}`);
const PAGE = 20;
// (A) セッション常駐型: オフセットをインスタンスのメモリに置く
function makeStatefulInstance(name) {
const sessions = new Map(); // sessionId -> offset
return {
name,
open(sessionId) { sessions.set(sessionId, 0); },
listItems(sessionId) {
if (!sessions.has(sessionId)) {
const e = new Error("session not found");
e.code = 404; // 実際のログではここが並びます
throw e;
}
const off = sessions.get(sessionId);
sessions.set(sessionId, off + PAGE);
return ITEMS.slice(off, off + PAGE);
},
};
}
// (B) ステートレス型: 署名付きの不透明カーソルを往復させる
const SECRET = crypto.randomBytes(32);
const sign = (body) =>
crypto.createHmac("sha256", SECRET).update(body).digest("base64url");
function encode(state) {
const body = Buffer.from(JSON.stringify(state)).toString("base64url");
return `${body}.${sign(body)}`;
}
function decode(token) {
const [body, mac] = String(token).split(".");
if (!body || !mac) throw new Error("malformed cursor");
const a = Buffer.from(mac), b = Buffer.from(sign(body));
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new Error("bad signature");
return JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
}
function makeStatelessInstance(name) {
return {
name,
listItems(cursor) {
const off = cursor ? decode(cursor).off : 0;
const slice = ITEMS.slice(off, off + PAGE);
const next = off + PAGE < ITEMS.length ? encode({ off: off + PAGE }) : null;
return { items: slice, nextCursor: next };
},
};
}
const CALLS = 200;
function runStateful(n) {
const pool = Array.from({ length: n }, (_, i) => makeStatefulInstance(`i${i}`));
const sid = crypto.randomUUID();
pool[0].open(sid); // 初期化要求は1台目に届いた前提
let fail = 0;
for (let k = 0; k < CALLS; k++) {
try { pool[k % n].listItems(sid); } catch { fail++; }
}
return fail;
}
function runStateless(n) {
const pool = Array.from({ length: n }, (_, i) => makeStatelessInstance(`i${i}`));
let cursor = null, fail = 0;
for (let k = 0; k < CALLS; k++) {
try {
const r = pool[k % n].listItems(cursor);
cursor = r.nextCursor; // null になったら先頭へ戻る
} catch { fail++; }
}
return fail;
}
for (const n of [1, 2, 3, 4]) {
const s = runStateful(n), l = runStateless(n);
console.log(
`instances=${n} stateful_fail=${s}/${CALLS} (${(s / CALLS * 100).toFixed(1)}%)` +
` stateless_fail=${l}/${CALLS} (${(l / CALLS * 100).toFixed(1)}%)`
);
}手元での結果です。
| インスタンス数 | セッション常駐型の失敗 | ステートレス型の失敗 |
|---|---|---|
| 1 | 0 / 200(0.0%) | 0 / 200(0.0%) |
| 2 | 100 / 200(50.0%) | 0 / 200(0.0%) |
| 3 | 133 / 200(66.5%) | 0 / 200(0.0%) |
| 4 | 150 / 200(75.0%) | 0 / 200(0.0%) |
失敗率は素直に (n-1)/n を辿ります。ここで目を引いたのは、むしろ1行目のほうでした。
1台構成では失敗が一度も出ません。つまり、開発機でもステージングでも、インスタンスを1つしか立てていない限り、この不具合は永久に姿を見せない設計になっています。スティッキーセッションを外した本番でだけ、それも「半分だけ」現れます。
私はこの数字を見てから、移行の順番を組み直しました。コードを直す前に、まず何台構成で回っているかを確かめる、という順番です。
セッションへの結合を機械で数える
目視のレビューは、こういう横断的な依存を取りこぼします。sessionId という変数名だけを追っても、transports や pagerState のような別名で同じ結合が生まれているためです。
そこで、結合そのものを重み付きで数えるスクリプトを書きました。
#!/usr/bin/env bash
# mcp-session-audit.sh — リモート MCP サーバーのセッション結合を洗い出す
# 使い方: ./mcp-session-audit.sh <src ディレクトリ>
set -uo pipefail
ROOT="${1:-src}"
[ -d "$ROOT" ] || { echo "no such dir: $ROOT" >&2; exit 2; }
SCORE=0
hit() { # hit <重み> <ラベル> <正規表現>
local w="$1" label="$2" re="$3" out n
out=$(grep -rInE "$re" "$ROOT" \
--include='*.ts' --include='*.js' --include='*.mjs' --include='*.py' 2>/dev/null)
n=$(printf '%s' "$out" | grep -c .)
if [ "$n" -gt 0 ]; then
SCORE=$((SCORE + w * n))
printf '\n[%s] %s件 (重み%s)\n' "$label" "$n" "$w"
printf '%s\n' "$out" | sed 's/^/ /' | head -12
fi
}
hit 3 "セッションヘッダーの直接参照" 'mcp-session-id|Mcp-Session-Id|MCP_SESSION_ID'
hit 3 "セッションIDを鍵にしたメモリ保持" 'sessionIdGenerator|(transports|sessions|sessionStore)\s*[:=]\s*(new Map|\{)'
hit 2 "セッション単位のインメモリ状態" '\[[a-zA-Z_]*[Ss]essionId\]|\.get\(\s*sessionId|\.set\(\s*sessionId'
hit 2 "セッション終了に依存した後始末" 'onsessionclosed|onclose|DELETE\s+/mcp|session.*(delete|close|terminate)'
hit 1 "スティッキー前提のインフラ設定" 'sticky|affinity|ip_hash|sessionAffinity'
printf '\n=== 結合スコア: %s ===\n' "$SCORE"
if [ "$SCORE" -eq 0 ]; then
echo "✅ セッション結合は検出されませんでした"
elif [ "$SCORE" -le 6 ]; then
echo "⚠️ 軽度: 参照箇所を個別に潰せば移行できます"
else
echo "🛑 重度: 状態の置き場所そのものを設計し直す必要があります"
fi動作を確かめるため、よくある書き方を詰め込んだ17行のサンプルに当てました。
[セッションヘッダーの直接参照] 1件 (重み3)
sample/src/server.ts:4: const sessionId = req.headers["mcp-session-id"] as string | undefined;
[セッションIDを鍵にしたメモリ保持] 2件 (重み3)
sample/src/server.ts:2:const transports = new Map<string, StreamableHTTPServerTransport>();
sample/src/server.ts:8: sessionIdGenerator: () => randomUUID(),
[セッション単位のインメモリ状態] 3件 (重み2)
sample/src/server.ts:5: let transport = sessionId ? transports.get(sessionId) : undefined;
sample/src/server.ts:15: const offset = pagerState[sessionId] ?? 0;
sample/src/server.ts:16: pagerState[sessionId] = offset + 20;
=== 結合スコア: 15 ===
🛑 重度: 状態の置き場所そのものを設計し直す必要があります17行で15点。行数あたりの密度としては、公式のサンプルをそのまま伸ばした構成ほど高く出ます。逆に言えば、点数の絶対値よりも「移行後に0へ落ちたか」を見る道具として使うのが向いています。
重みの根拠も書いておきます。ヘッダー参照とトランスポート保持(重み3)は、ヘッダーが消えた時点で確実に壊れます。セッション単位の状態(重み2)は静かに壊れます。インフラのスティッキー設定(重み1)は、消しても機能は壊れません。壊れ方の派手さではなく、気づきにくさで重みを付けています。