MCP移行前にSession依存を検出するPython
ステートレス化したMCPサーバーで、どこに残っていた状態が消えるのか。ロードバランサーの設定だけを直して終えると、ここで事故る。
7月28日に正式化予定のMCP 2026-07-28 は、プロトコル層の initialize / initialized と Mcp-Session-Id を外す。RCの段階では、能力確認は server/discover へ移り、各リクエストが自分のプロトコル情報を持つ形になっている。普通のラウンドロビン配下へ置きやすくなるのは確か。ただし、買い物かごや編集中の文書まで消えるわけではない。
状態を必要とするなら、ツールの戻り値で basket_id のようなハンドルを返し、次の呼び出しで引数として渡す。移行の本体は「セッションを捨てる」作業より、この契約を表に出す作業だと思う。
先に見るのはインフラではなく、ツールの引数
旧構成では、クライアントがどのサーバーへ来たかと、どの作業の続きかが Mcp-Session-Id の裏に隠れやすい。ステートレス化後もアプリケーションの状態は持てるが、ツール呼び出しだけを見て追える形にする必要がある。
旧構成と移行後の構成を図にすると、差分はここに集約される。
自分はこの手の移行で、まずIngressのsticky設定を探しがちだった。でも先に調べるべきなのは、後続ツールが何のIDも受け取らずに動いていないかだった。add_item や checkout に作業対象の引数がなければ、接続先をどれだけきれいにしても状態は引き継げない。
以下は、JSON化したツール実行traceに対して、ハンドルの生成と受け渡しを検査する最小のPythonだ。contracts を自分のツール名とID名に置き換えて使う。
from __future__ import annotations
def audit_state_handles(calls: list[dict], contracts: dict[str, dict]) -> list[str]:
known_handles: set[str] = set()
errors: list[str] = []
for index, call in enumerate(calls, start=1):
rule = contracts.get(call["tool"], {})
produced = rule.get("produces")
required = rule.get("requires")
if produced:
value = call.get("result", {}).get(produced)
if not value:
errors.append(f"#{index} {call['tool']}: {produced} を返していない")
else:
known_handles.add(value)
if required:
value = call.get("arguments", {}).get(required)
if not value:
errors.append(f"#{index} {call['tool']}: {required} がない")
elif value not in known_handles:
errors.append(
f"#{index} {call['tool']}: {required}={value!r} は、このtraceで生成されていない"
)
return errors
contracts = {
"create_basket": {"produces": "basket_id"},
"add_item": {"requires": "basket_id"},
"checkout": {"requires": "basket_id"},
}
ok_trace = [
{"tool": "create_basket", "arguments": {}, "result": {"basket_id": "b-781"}},
{"tool": "add_item", "arguments": {"basket_id": "b-781", "sku": "book-42"}},
{"tool": "checkout", "arguments": {"basket_id": "b-781"}},
]
unknown_handle_trace = [
{"tool": "create_basket", "arguments": {}, "result": {"basket_id": "b-781"}},
{"tool": "add_item", "arguments": {"basket_id": "b-999", "sku": "book-42"}},
]
assert audit_state_handles(ok_trace, contracts) == []
assert audit_state_handles(unknown_handle_trace, contracts) == [
"#2 add_item: basket_id='b-999' は、このtraceで生成されていない"
]
print(audit_state_handles(ok_trace, contracts))
print(audit_state_handles(unknown_handle_trace, contracts))
Python 3.12.13で実行した出力はこうなった。
[]
["#2 add_item: basket_id='b-999' は、このtraceで生成されていない"]
最初は「必須引数があるか」だけを見ていた。これだと basket_id="適当な文字列" が通る。作成ツールの戻り値と突き合わせる検査まで入れると、少なくともテスト用traceで状態の出所を追える。実運用では、ログにツール名、引数のハンドル、結果のハンドルを残せば同じ検査をCIへ寄せられる。
ハンドルを渡しても認可は渡さない
ここは混ぜない方がいい。basket_id は対象を指すための値であって、アクセス権ではない。checkout 側では、そのハンドルの所有者やテナントを認証済みの主体と照合する。IDを明示したことで、別ユーザーのIDを渡せるようにしてはいけない。
もう一つ、サーバーから任意のタイミングで通知する設計、購読、接続に紐付く分離が必要なら、ステートレス化を急がない。2026-07-28対応のSDKとクライアントが混在する期間は、旧プロトコルを残す経路も要る。RCは最終仕様ではないので、固定した日付で実装を決め打ちしないことも大事になる。
おわりに
今回の変更で消えるのは、MCPが面倒を見ていた接続単位の状態だ。業務上の状態までなくす必要はない。むしろ basket_id や draft_id をツール契約として出した方が、traceもテストも障害調査も楽になる。
移行前に、状態を作るツール、状態を使うツール、認可を確かめる場所を1枚に書き出す。この監査をしてからsticky sessionを外せば、ステートレス化はインフラの模様替えで終わらず、MCPのツール設計を少しまともにできる。
参考: