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)は、消しても機能は壊れません。壊れ方の派手さではなく、気づきにくさで重みを付けています。
共有ストアに逃がしても助からない
監査で結合が見つかったとき、最初に浮かぶ手当てはたいてい同じだと思います。「インメモリの Map を Redis に移せばよい」というものです。私も一度そう考えました。
ここが、事前の予想と逆だった部分でした。
// shared-store-check.mjs
const shared = new Map(); // Redis 相当の共有ストア
function makeInstance() {
return { listItems(sessionId) {
if (!sessionId) { const e = new Error("no session id in request"); e.code = 400; throw e; }
if (!shared.has(sessionId)) shared.set(sessionId, 0);
const off = shared.get(sessionId);
shared.set(sessionId, off + 20);
return off;
}};
}
共有ストアが解決するのは「どのインスタンスが受けても同じ状態を読める」という問題だけです。廃止されるのは、その状態を引くための鍵そのものでした。手元での確認結果を並べます。
| 条件 | 200回中の失敗 | 実際の挙動 |
| 共有ストア+セッションヘッダーあり | 0 件(0.0%) | 意図どおり進む |
| 共有ストア+ヘッダーなし(07-28 以降) | 200 件(100.0%) | 鍵が無く全滅 |
| リクエストごとに UUID を振り直す応急処置 | 0 件(0.0%) | 毎回1ページ目・ストアが201件へ膨張 |
三行目が、いちばん危ないと感じた挙動です。エラーは一件も出ません。監視のエラー率は静かなままで、ツールは200回とも「成功」を返します。返ってくるのが毎回同じ先頭ページである、という点だけが違います。
エージェントは、同じ20件を延々と受け取りながら作業を続けます。しかも共有ストアには、二度と読まれないエントリが201件積み上がっていました。放置すればメモリ使用量だけが伸びていきます。
500 番台のエラーとして落ちてくれるほうが、運用としてはよほど親切です。落ちない壊れ方をどう検出するかについては、Claude Code の実行を OpenTelemetry で観測するで扱った、エラー率以外の指標を持つという考え方がそのまま効きます。
状態を三つに仕分ける
鍵が無くなる前提に立つと、やるべきことは「状態をどこへ移すか」ではなく「その状態を誰が持つべきだったか」の再配置になります。手元のサーバーでは、三つの箱に仕分けました。
捨ててよい状態
トランスポートのインスタンス、直近のリクエストのログバッファ、初期化時のクライアント情報。呼び出しをまたいで持ち越す理由が無いものです。
私の場合、transports Map の中身はほぼここに入りました。リクエストごとに生成して捨てる形に変えると、onsessionclosed に紐づけていた後始末も同時に消えます。ヘッダー廃止の前に片付けられる、いちばん安い部分です。
クライアントに返す状態
ページングのオフセット、検索結果の絞り込み条件、多段処理の途中経過。「次の呼び出しで続きから」を実現するための状態です。
これらはサーバーに置かず、不透明なトークンとしてツールの戻り値に載せ、次の引数で返してもらいます。MCP のページングはもともと cursor と nextCursor の往復で表現されますので、規約から外れる話ではありません。
業務の識別子で永続化する状態
下書き記事、アップロード中のファイル、承認待ちの申請。セッションが切れても消えては困るものです。
これは共有ストアが正解の領域ですが、鍵をセッションIDにしてはいけません。draft:{userId}:{draftId} のように、業務上の識別子で引ける形に付け替えます。付け替えた結果、セッションが切れても再開できるようになり、副産物として「途中で落ちた作業を拾い直す」導線も手に入りました。
署名付きカーソルを実装する
二番目の箱をどう実装するかが本題になります。クライアントに状態を返す以上、改ざんされる前提で設計します。
// cursor.mjs
import crypto from "node:crypto";
const CURSOR_SECRET = Buffer.from(process.env.MCP_CURSOR_SECRET ?? "", "base64");
if (CURSOR_SECRET.length < 32) {
// 起動時に落とします。全インスタンスへ同一の値を配る必要があるためです
throw new Error("MCP_CURSOR_SECRET must be >=32 bytes (base64)");
}
const CURSOR_VERSION = 1;
const CURSOR_TTL_SEC = 900;
export function encodeCursor(state) {
const payload = { v: CURSOR_VERSION, exp: Math.floor(Date.now() / 1000) + CURSOR_TTL_SEC, ...state };
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
const mac = crypto.createHmac("sha256", CURSOR_SECRET).update(body).digest("base64url");
return `${body}.${mac}`;
}
export class CursorError extends Error {
constructor(reason) {
super(`invalid cursor: ${reason}`);
this.code = -32602; // JSON-RPC の Invalid params に寄せます
this.reason = reason;
}
}
export function decodeCursor(token) {
if (typeof token !== "string" || token.length > 4096) throw new CursorError("malformed");
const dot = token.indexOf(".");
if (dot <= 0) throw new CursorError("malformed");
const body = token.slice(0, dot), mac = token.slice(dot + 1);
const expected = crypto.createHmac("sha256", CURSOR_SECRET).update(body).digest("base64url");
const a = Buffer.from(mac), b = Buffer.from(expected);
// 長さを先に見ないと timingSafeEqual が例外を投げます
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new CursorError("signature");
let payload;
try { payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")); }
catch { throw new CursorError("payload"); }
if (payload.v !== CURSOR_VERSION) throw new CursorError(`version ${payload.v}`);
if (typeof payload.exp !== "number" || payload.exp < Math.floor(Date.now() / 1000)) {
throw new CursorError("expired");
}
return payload;
}
手元で確かめた挙動と数値です。
| 確認項目 | 結果 |
| 往復(off と検索語を格納) | 復元成功・トークン106バイト |
| 最小構成のトークン長 | 88バイト |
| 本体だけ差し替えた改ざん | signature で拒否(code -32602) |
| exp を過去にしたトークン | expired で拒否 |
| 秘密鍵が未設定のまま起動 | 起動時に例外で停止 |
| 署名 20万回の平均 | 3.809μs |
| 検証 20万回の平均 | 5.122μs |
署名と検証を合わせて 9μs 前後。外部 API を1回叩くツールの往復を 20ms とすると、増える処理時間は 0.05% にも届きません。ステートレス化のコストを心配していましたが、実測してみると桁が違いました。
いくつか、書きながら判断が要った点を残しておきます。
秘密鍵を起動時に検証して落とす設計は、あえて厳しくしています。鍵が配られていない1台が混じると、そのインスタンスに当たった呼び出しだけが「不正なカーソル」を返します。先ほどの表の三行目と同じで、確率的にしか起きない不具合はいちばん潰しにくいためです。起動しないほうが、結果的に早く気づけます。
exp を15分に置いたのは、エージェントの一連の作業がその範囲に収まることが多かったためです。長い調査タスクを扱うサーバーであれば、もう少し伸ばす判断もあり得ます。ただし無期限にはせず、必ず有効期限を持たせることをお勧めします。
暗号化ではなく署名にとどめている点も意図的です。カーソルの中身は、クライアント側で読めても困らないものだけに限ります。読まれて困る値が入りそうになったら、それは二番目の箱ではなく三番目の箱に属する状態です。
ツール定義にカーソルを出す
サーバー側の実装が終わったら、ツールのスキーマにも反映します。ここを省くと、モデルはカーソルを返してよいことに気づけません。
server.registerTool("list_articles", {
description: "記事の一覧を新しい順に返します。続きを取得する場合は、直前の応答の nextCursor をそのまま cursor へ渡してください。",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "絞り込みの検索語" },
cursor: {
type: "string",
description: "直前の応答の nextCursor をそのまま渡します。値の中身を編集しないでください。",
},
},
},
}, async ({ query, cursor }) => {
let state = { off: 0, q: query ?? "" };
if (cursor) {
try {
state = decodeCursor(cursor);
} catch (e) {
// 期限切れは「先頭から取り直してください」と伝えるほうが復帰が早くなります
return {
isError: true,
content: [{ type: "text", text: `カーソルが無効です (${e.reason})。cursor を省いて呼び直してください。` }],
};
}
}
const page = await fetchArticles(state.q, state.off, 20);
const next = page.length === 20 ? encodeCursor({ off: state.off + 20, q: state.q }) : null;
return {
content: [{ type: "text", text: JSON.stringify({ items: page, nextCursor: next }) }],
};
});
description に「編集しないでください」と書いているのは、実際にモデルがカーソルの中身を推測して組み立てようとする場面があったためです。不透明であることを言葉で伝えておくと、余計な試行が減りました。
エラーを例外で投げずに isError で返しているのも、同じ理由です。期限切れは異常ではなく、先頭から取り直せば済む状態にすぎません。復帰の手順を本文に書いておけば、モデルは次のターンで自力で戻ります。
移行の順番と、外し方
一気に切り替えず、以下の順で進めました。番号どおりに実行すると、途中でロールバックできる地点が残ります。
- 監査スクリプトを CI に入れ、現在のスコアを記録します。ここでスコアを下げる作業を始める前に、数字を残しておくことが後の判断材料になります。
- 「捨ててよい状態」から片付けます。トランスポートを使い捨てに変え、
onsessionclosed 依存の後始末を消します。この段階では挙動が変わらないため、安全に先行できます。
- カーソル方式を追加します。この時点ではセッション常駐の経路も残し、
cursor が来たら新方式、来なければ従来どおりに動かします。両対応の期間です。
- インスタンスを2台に増やし、再現スクリプトを本番相当の構成で回します。失敗が0であることを確認できたら、はじめてスティッキーセッションの設定を外します。
- 監査スコアが0になったことを確認し、セッション常駐の経路とヘッダー参照を削除します。
順番の要は4番です。スティッキー設定を外す前に2台構成で検証しておかないと、外した瞬間が初めての本番検証になってしまいます。私はこの工程を先に済ませておいたおかげで、切り替え当日に触るのは設定ファイル1行だけになりました。
ロードバランサ側の設定を消す作業は、いちばん最後に置きます。消してから戻すより、残したまま「使われていない」状態を作るほうが安全です。
数えられるようにしてから移す
今回いちばん効いたのは、コードを書き換えたことよりも、壊れ方を数字にしてから着手したことでした。
「1台では0%、2台では50%」という表を先に手に入れていたため、テストが全部通っている状態を信用しないで済みました。監査スコアも同じ役割で、移行が終わったかどうかを気分ではなく数値で判定できます。
まず手を動かすとしたら、お使いの MCP サーバーのソースに監査スクリプトを当てて、スコアを一度記録してみてください。0 であれば 7月28日は何も起きません。0 でなければ、どの箱に属する状態なのかを仕分けるところから始められます。
仕様はまだリリース候補の段階ですので、実装に入る前に一次情報での確認をお願いいたします。実運用の参考になれば幸いです。お読みいただきありがとうございました。