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?

MCPのステートレス化、4言語SDKの移行実測8本を壊れる層ごとに整理した

0
Posted at

はじめに

MCP(Model Context Protocol)の 2026-07-28 仕様で、initialize ハンドシェイクと Mcp-Session-Id ヘッダーが廃止されました。移行の記事を読むと「既存クライアントは壊れない」という話と「400 が返り続けて通らない」という話が両方出てきて、どちらを信じればよいのか迷います。

対象読者は、MCP サーバーやクライアントを自作していて、2026-07-28 仕様への SDK 移行をどこから手を付けるか決めたい開発者です。

筆者は 2026-07 から 09 にかけて、Python・TypeScript・Go・Rust の公式 SDK を相手に移行まわりの実機検証を 8 本書きました。1 本ずつ読むと個別の不具合に見えますが、並べると「どの層で壊れるか」がはっきり分かれます。各記事で実測した結果を、壊れる層ごとに束ね直したのがこの記事です。数値と挙動はすべて元記事の公開時点のもので、本記事で再実行したものではありません。

8本の一覧

# 対象 SDK・言語 確かめたこと 結果の一言 元記事
1 Python mcp 1.28.0 stateless_http の有無でセッション ID なしのリクエストがどうなるか 既定は 400 Missing session ID、stateless_http=True で通る 447
2 TypeScript codemod 2.0.0-beta.3 公式 codemod を ESM と CommonJS に流す require() は 0 行変換なのに「Migration complete」 466
3 TypeScript(npm) 仕様確定日の npm パッケージの中身 旧 sdk 1.30.0 は旧仕様のまま、新仕様は 9 パッケージ側に実装 532
4 TypeScript v1 1.30.0 / v2 2.0.0 同じリクエストを 9 項目で比較 v1 クライアントは v2 サーバーに無改造で繋がる 568
5 TypeScript v2 2.0.0 非推奨 API を legacy / modern の両 era で呼ぶ Roots / Sampling / ping は modern era で即例外、Logging だけ例外にならない 543
6 TypeScript v2 2.0.0 Tasks 拡張の 6 メソッド 自前ハンドラで動いたのは 4 つ、tasks/update は Method not found 588
7 Go SDK v1.7.0 Stateless の値・ヘッダー・_meta を変えた 11 条件の表 フラグに加えてリクエスト側の必須が 3 つ 574
8 Rust rmcp 3.2.0 legacy_session_mode の on / off 既定ではカウンターが 1→1→1、レガシーで 1→2→3 923

公開日は 447 が 2026-07-09、466 が 2026-07-12、532 が 2026-07-28、543 が 2026-08-05、568 が 2026-08-13、574 が 2026-08-17、588 が 2026-08-22、923 が 2026-09-02 です。447 と 466 は仕様のリリース候補(RC)段階の検証です。

2026-07-28 仕様で変わったこと

公式ブログの告知から、移行に効く点だけを抜き出します。

  • initialize / initialized のやり取りと Mcp-Session-Id ヘッダーを廃止。各リクエストがプロトコルバージョン・クライアント識別・ケイパビリティを _meta に載せて単独で届く
  • Streamable HTTP のリクエストに Mcp-Method と Mcp-Name ヘッダーを必須化。ゲートウェイが JSON 本文を解析せずにルーティングできる
  • 一覧・read 系の結果に ttlMs と cacheScope が付く
  • Tasks は io.modelcontextprotocol/tasks 拡張へ移動
  • Roots・Sampling・Logging は非推奨。最低 12 か月の移行期間がある
  • 状態を持ちたい場合は、ツールから明示的なハンドルを発行し、モデルに引数として返させる
  • Tier 1 SDK(TypeScript・Python・Go・C#)が対応、Rust SDK はベータで対応

仕様側で「何が消えたか」ははっきりしています。分かりにくいのは、SDK ごとにその変更がどの層に、どの既定値で入ってきたかです。

言語・SDK 別の対応状況

8 本の検証時点で確認できた範囲を言語ごとに並べます。

言語 検証したバージョン セッションなしで動く設定 既定値 2026-07-28 の受け方 元記事
Python mcp 1.28.0(RC 時点) stateless_http=True False(セッションあり) 新仕様リクエストは未検証 447
TypeScript @modelcontextprotocol/server 2.0.0 createMcpHandler セッションを張らない _meta の宣言で legacy / modern を振り分け 532 / 568 / 543 / 588
Go go-sdk v1.7.0 StreamableHTTPOptions.Stateless = true false(新仕様は 400) Stateless=true かつヘッダー 2 つと _meta 574
Rust rmcp 3.2.0 legacy_session_mode = false false(セッションなし) initialize で 2026-07-28 を要求しても 2025-11-25 に引き下げ 923
C# 未検証 未検証 未検証 未検証 なし

既定値の向きが言語ごとに逆になっている点は、後の「共通していたパターン」で取り上げます。

移行で壊れる層

8 本で観測した「壊れ方」は、SDK 本体の 5 つの層と、その手前の周辺ツールに分かれました。リクエストが届いてから応答が返るまでのどこで止まるかを図にすると次のようになります。

層1: サーバー設定(セッションなしを受け付けるか)

最初の関門は、サーバーがセッション ID なしのリクエストを受ける設定になっているかです。447 で Python mcp 1.28.0 を実測した結果では、既定の stateless_http=False のサーバーに Mcp-Session-Id を付けずに tools/call を送ると 400 と {"error":{"code":-32600,"message":"Bad Request: Missing session ID"}} が返り、stateless_http=True にすると同じリクエストが 200 で通りました。

Go では 574 の条件 A で、Stateless=false のサーバーに新仕様のリクエストを送ると Bad Request: protocol version "2026-07-28" is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true) というプレーンテキストの 400 が返りました。Rust は 923 の実測で向きが逆で、legacy_session_mode = true にしたときだけ initialize なしの tools/call が HTTP 422 Unexpected message, expect initialize request で弾かれます。

なぜこうなるかというと、447 が引用した SEP-2567 によれば PHP を除く公式 SDK はすでに「セッション ID を発行しない」ステートレスモードを持っており、新仕様はそれを前提に寄せる変更だからです。各 SDK はそのモードを既定にするかどうかを別々に決めています。447 が引用した SEP-2567 の 1,000 リポジトリ調査では、本当に設計し直しが必要なサーバーは 3.7%(4% 未満)でした。

層2: リクエストの形(ヘッダーと _meta)

サーバー側をステートレスにしても、リクエストの形が新仕様に揃っていなければ通りません。568 で TypeScript v2 を実測した結果、_meta に 2026-07-28 を宣言したモダン経路では Mcp-Method を外すと 400 -32020、tools/call で Mcp-Name を外しても 400 -32020 でした。_meta 側は clientInfo と clientCapabilities の両方が要り、欠けると -32602 で不足キーを名指しされます。ヘッダーの検査は _meta の検査より先に走りました。

Go の 574 では、Stateless=true の後にさらに Mcp-Protocol-Version ヘッダー、本文の method と一致する Mcp-Method ヘッダー、_meta の clientCapabilities の 3 つが必要でした。成功した条件 G のリクエストは protocolVersion と clientCapabilities だけを載せたもので、clientInfo は入っていません。

なぜ厳しいかというと、ヘッダーの目的がロードバランサーやプロキシに本文を開かせずに振り分けさせることにあり、本文と食い違えばルーティング判断が壊れるからです。

層3: SDK の交渉テーブル(2026-07-28 を名乗れるか)

3 つ目は、SDK 自体が 2026-07-28 を交渉できるかという層です。532 で 2026-07-28 当日に npm を確認したとき、@modelcontextprotocol/sdk 1.30.0 の LATEST_PROTOCOL_VERSION は 2025-11-25 で、Mcp-Method も ttlMs も実装されていませんでした。新仕様の中身は、約 6 時間後に公開された @modelcontextprotocol/core / server / client など 9 パッケージ側に入っていました。その新パッケージでも定数は 2025-11-25 のままです。

588 では、この定数の影響が Tasks 拡張に出ました。SUPPORTED_PROTOCOL_VERSIONS に 2026-07-28 が無く、initialize の交渉結果は 2025-11-25 に落ちます。その結果、tools/call から { task } だけを返すと -32602 Invalid tools/call result で止まり、6 メソッドのうち、自前ハンドラを書いて動いたのは tasks/get / tasks/result / tasks/list / tasks/cancel の 4 つで、tasks/update は Method not found でした。Rust の 923 でも、rmcp 3.2.0 は initialize で 2026-07-28 を要求すると 2025-11-25 を返しました。

なぜ「定数が古いのに新仕様が動く」状態がありうるかというと、新仕様ではプロトコルバージョンがハンドシェイクで交渉する値ではなく、リクエストごとの _meta に載る値になったからです。TypeScript v2 はこの _meta で era を判定していました。

層4: era ごとの API の可否

TypeScript v2 では、同じサーバーでもリクエストの era によって使える API が変わります。543 の対照実験では、legacy era(2025-11-25 を交渉)では listRoots() / createMessage() / sendLoggingMessage() / ping() の 4 つとも成功しました。modern era(_meta に 2026-07-28)では roots/list / sampling/createMessage / ping が SdkError[METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION] で即例外になり、notifications/message(Logging)だけが例外になりませんでした。

なぜ Logging だけ違うかというと、ランタイムのガード _assertPushApiInServedEra の呼び出し先が ping / sampling/createMessage / elicitation/create / roots/list の 4 つだけで、いずれもサーバーからクライアントへリクエストを送る方向の API だからです。Logging は通知なので往復を必要としません。公式ブログによれば、サーバー起点で入力が要る場面は Multi Round-Trip Requests(MRTR)で扱う設計に移っており、543 の記述でもその置き換え先は inputRequired(...) です。公式の「12 か月は動く」は仕様レベルの約束で、SDK が modern era を処理している最中の挙動とは別の話でした。

層5: 状態の持ち方

ここは失敗が HTTP ステータスに出ない層です。923 で rmcp 3.2.0 の既定モードに increment を 3 回送ると、戻り値は 1 → 1 → 1 でした。legacy_session_mode = true でセッション ID を付けると 1 → 2 → 3 になります。StreamableHttpService::new の第 1 引数のクロージャがリクエストごとに呼ばれ、Arc<Mutex<i32>> がその都度作り直されるためです。

一方 447 の Python 検証では、stateless_http=True でもカウンターは 1 → 2 と増え続けました。カウンターがモジュール変数で、プロセスの寿命だけに依存していたからです。447 はこの結果について、複数レプリカへ水平スケールした瞬間にプロセスごとに状態が分裂すると注記しています。

周辺ツール: codemod とパッケージ

SDK 本体の手前で壊れるのが周辺ツールです。466 で @modelcontextprotocol/codemod@2.0.0-beta.3 を CommonJS のサーバーに流すと、Changes: の行が出ないまま package.json から @modelcontextprotocol/sdk だけが削除され、Migration complete. Review the changes and run your build/tests. と表示されました。そのまま npm install して起動すると Error: Cannot find module '@modelcontextprotocol/sdk/server/mcp.js'(MODULE_NOT_FOUND)で止まります。

532 では、新パッケージ群の engines.node が >=20 で、旧 SDK の >=18 から上がっていることも確認しています。Go の 574 でも、go-sdk v1.7.0 は requires go >= 1.25.0 で、Go 1.24 系に固定した CI では失敗するとしています。codemod については、466 が「解析対象が import 宣言に絞られているとみられる」と推測しています(ヘルプには明記なし)。ランタイム要件は事実だけを並べると、Node は engines.node が >=20 に上がり、Go は Go 1.24 系に固定した CI では失敗する、という記述です。

共通していたパターン

8 本を並べて、筆者が個別記事では書けなかった共通点が 4 つあります。

1. 既存クライアントは壊れず、壊れるのは新経路に片足だけ乗せたとき。 TypeScript v2 では v1 クライアントが無改造で繋がり(568)、Go の Stateless=true サーバーは旧仕様の initialize にも _meta なしの tools/list にも 200 を返しました(574)。TypeScript v2 で 400 が出たのは、新仕様を宣言しておきながらヘッダーか _meta が 1 つ欠けたときです。Go ではこれに加えて、同じ完全なリクエストでも Stateless=false なら 400 になります(574 の条件 H)。移行の順序はサーバーが先、クライアントが後で成立します。

2. 失敗の出方が静かなものほど危ない。 codemod は 0 行変換で「Migration complete」と出し(466)、rmcp の既定モードは HTTP 200 のまま値だけが間違い(923)、Go の設定ミスだけは JSON-RPC ではないプレーンテキストで返りました(574)。ステータスコードで落ちる層 2 より、成功に見える層 5 や周辺ツールの方が見つけにくいと感じました。

3. era の判定はバージョン定数ではなくリクエスト側にある。 TypeScript の v1・v2、rmcp のいずれでも最新の定数や交渉結果は 2025-11-25 でした(532 / 568 / 543 / 588 / 923)。それでも TypeScript v2 と Go では _meta に 2026-07-28 を載せたリクエストが新仕様として処理され、応答に ttlMs と cacheScope が付きました。移行済みかどうかを定数で判定すると読み違えます。568 が書いたとおり、実リクエストの応答を見るほうが確実です。

4. 言語ごとに既定値の向きが逆。 Python 1.28.0 は既定でセッションありで、ステートレスにはフラグが要ります。Go v1.7.0 は Stateless=true を明示しないと新仕様を受け付けません。rmcp 3.2.0 は既定でセッションなしで、旧来の挙動に戻すフラグが要ります。同じ「状態を持つツール」を移植すると、Go では新仕様のリクエストが 400 で弾かれ、Rust では initialize なしのリクエストが通ったうえで値が 1 に戻ります。言語をまたいで同じ設計を持ち込むときは、どちらに倒れているかを最初に確認する必要があります。

細部の違いもありました。最小サーバーの tools/list 応答の cacheScope は TypeScript v2 で "private"(568)、Go で "public"(574)で、ttlMs はどちらも 0 でした。_meta の必須キーも、TypeScript は clientInfo と clientCapabilities、Go は protocolVersion と clientCapabilities(clientInfo なし)で 200 になっています。

移行チェックリスト

8 本の実測から、確認順に並べたチェックリストです。

順 層 確認すること 失敗したときの症状 元記事
1 周辺 ランタイム要件(Node >=20・Go >=1.25.0) Node は警告または失敗(engine-strict 次第)、Go は toolchain 固定の CI で失敗 532 / 574
2 周辺 codemod 後に git diff で実装ファイルが書き換わったか MODULE_NOT_FOUND 466
3 層1 サーバーをステートレス設定にしたか(Python stateless_http / Go Stateless) 400(Go はプレーンテキスト) 447 / 574
4 層2 Mcp-Method(と tools/call なら Mcp-Name)を本文と一致させて送るか -32020 568 / 574
5 層2 _meta に protocolVersion と clientCapabilities(TypeScript は clientInfo も)を載せたか -32602 568 / 574
6 層3 SDK が 2026-07-28 を交渉できるか。Tasks 応答に依存していないか -32602 Invalid tools/call result 588 / 923
7 層4 Roots / Sampling / ping をツールハンドラ内で呼んでいないか METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION 543
8 層5 同じツールを 2 回呼んで、2 回目の値が変わるか 例外なしで値が戻る 923 / 447
9 監視 GET での SSE 接続や ping によるヘルスチェックを使っていないか Go は 405、TS modern era は例外 574 / 543

おわりに

8 本の検証は、どれも単体では「この SDK のこの挙動」という小さな話でした。束ねると、ステートレス化の移行は「サーバー設定」「リクエストの形」「交渉テーブル」「era ごとの API」「状態の持ち方」の順に壊れどころが並んでいて、ステータスや例外で落ちる層 1〜4 より、成功に見える層 5 と周辺ツールの失敗のほうが見つけにくいという形が見えます。既存クライアントを守りたいだけなら、TypeScript と Go の実測ではサーバーを先にステートレス側へ倒す順序が安全でした(Python と Rust は新仕様のリクエストを試していません)。

数値と挙動は各記事の公開時点(2026-07-09〜2026-09-02)のもので、Python の新仕様リクエストと C# SDK はまだ検証していません。SDK のバージョンを上げたら、チェックリストの 8 番(同じツールを 2 回呼ぶ)だけでも先に試すと、静かな壊れ方を早く拾えます。

関連記事

参考リンク

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?