MCP がステートレスになった:2026-07-28 リビジョンが私たちのサーバーから取り去ったもの
MCP サーバーを運用しているなら、どこかにセッションを持っているはずです。おそらくテーブル、あるいはメモリ上の map でしょう。クライアントが接続し、initialize を送り、Mcp-Session-Id を受け取り、以降のすべてのリクエストでそのヘッダーを持ち回ります。あなたはその行を保存します。しばらくしたら期限切れにします。リクエストがその行を持つインスタンスに届くようにするか、インスタンス間で状態を共有するかします。
2026-07-28 リビジョンはそれを削除しました。deprecated にしたのではありません。プロトコルのコアから取り除いたのです。ハンドシェイクは消え、セッションヘッダーも消え、いまやすべてのリクエストが自分自身のプロトコルバージョンとクライアント identity を運びます。リリース記事の言い方を借りれば、"any request can now land on any server instance behind a plain round-robin load balancer without needing shared storage" ——どのリクエストも、共有ストレージを必要とせずに、素の round-robin ロードバランサーの背後にあるどのサーバーインスタンスにも届けられるようになった、ということです。
Archyl の MCP サーバーは、新しいリビジョンを話すクライアントに対応するようになりました。この記事は、それに何が必要だったか、そのあと何を計測したか、最初の一周で間違えたひとつのこと、そして私たちがやっていないことについてです。MCP サーバーをメンテナンスしている人にとって面白いのは、おそらく中盤の設計判断、新しい transport を監査したことで古いほうから出てきたバグ、そして自分のサーバーを点検しにいくための最後のチェックリストでしょう。
リビジョンが実際に削除したもの
changelog からそのまま、サーバー実装に触れる部分だけを挙げます。
- プロトコルレベルのセッションと
Mcp-Session-Idヘッダーが Streamable HTTP transport から削除されました。リスト系のエンドポイントは接続ごとに変わらなくなります。 initialize/notifications/initializedのハンドシェイクが削除されました。すべてのリクエストが自分のプロトコルバージョンとクライアント capability を_metaに載せ、Streamable HTTP では同じバージョンがMCP-Protocol-Versionヘッダーで運ばれます。server/discoverが新設され、必須になりました。 サーバーはこれを実装しなければならず(MUST)、サポートするプロトコルバージョン、capability、identity を告知します。クライアントは何よりも先にこれを呼んでもよく(MAY)、逆にいきなりリクエストを送ってバージョンエラーを処理するのも自由です。ping、logging/setLevel、notifications/roots/list_changedが削除されました。- バージョンの不一致は
UnsupportedProtocolVersionErrorを返し、サーバーが実際にサポートするバージョンを列挙するので、クライアントは再試行できます。
ほかにも入っています(Multi Round-Trip Requests、subscriptions/listen、キャッシュ可能なリスト結果、番号を振り直されたエラーコードのブロック、authorization の強化)。それらのうち何をやって何を飛ばしたかは後で戻ってきます。上の 5 つは、サーバーの機能ではなく形そのものを変えるものです。
正確に言っておく価値がある点がひとつあります。判断が変わるからです。これはもうリリース候補ではありません。リリース候補は 2026 年 5 月 21 日にロックされ、SDK メンテナーとクライアント実装者のために 10 週間の検証ウィンドウが開かれました。そのウィンドウは仕様が出荷された 2026 年 7 月 28 日に閉じ、バージョニングのページはいま 2026-07-28 を "the current protocol version" ——現行のプロトコルバージョン——と呼んでいます。Tier 1 の SDK 4 つ(TypeScript、Python、Go、C#)はリリース当日からこれを話し、Rust はベータです。RC が落ち着くまで待っていたのなら、落ち着きました。
それが 181 個の tool を持つサーバーにとって何を意味したか
Archyl の MCP サーバーは C4 モデルの上に 181 個の tool を公開しています。プロジェクト、システム、container、component、relationship、ADR、ドキュメント、contract、conformance、drift、DORA、ownership。この変更の前は、181 個すべてがセッションの背後にありました。
具体的には、私たちのバックエンドでは:
- 接続ごとに
mcp_sessionsテーブルに行が 1 つ発行され、24 時間で期限切れになり、バックグラウンドの goroutine が古い行と期限切れの行を掃除していました。 - SSE のレスポンスチャネルはサーバー struct 上の
map[string]chan *JSONRPCMessageに、セッション ID をキーとして置かれていました。これが接続を、それを開いたプロセスに固定していました。この map はその後移動しましたが、その理由は好みの問題ではなくバグでした。あとで戻ってきます。 - 4 つのハンドラー(
tools/list、tools/call、resources/list、resources/read)が同じ 3 行で始まっていました。
if !session.Initialized {
return s.errorResponse(msg.ID, ErrCodeInvalidRequest, "Session not initialized", nil)
}
この guard が面白いところです。新しいプロトコルが答え不能にしてしまった問いを投げかけているからです。この呼び出し元はハンドシェイクを完了したか? 完了すべきハンドシェイクは存在しません。
変更を小さく保った判断
魅力的に見えるのは、その 4 つのハンドラーにステートレスであることを教える手です。条件をもうひとつ足す、あるいは各チェックの前に session.Stateless || を置く、あるいは全体をミドルウェアに引き上げる。
私たちはそのどれもやりませんでした。guard には手を触れていません。代わりに、2026-07-28 を宣言したリクエストには、そのリクエスト 1 回のためだけに組み立てられたインメモリのセッションが与えられ、それが構成上 guard を満たします。
func (s *Server) NewStatelessSession(userID, organizationID uuid.UUID, protocolVersion string) *Session {
now := time.Now()
return &Session{
Session: &mcpsession.Session{
ID: "",
UserID: userID,
OrganizationID: organizationID,
Initialized: true,
ProtocolVersion: protocolVersion,
Transport: "streamable",
LastAccessedAt: now,
CreatedAt: now,
},
Stateless: true,
}
}
何も永続化されません。ID は割り当てられません。SSE チャネルも登録されません。Initialized: true は嘘でも抜け道でもありません。このリビジョンのもとでは、そのリクエストは本当に初期化済みなのです。プロトコルが自分のバージョンを運んでおり、本来なら完了させたはずのハンドシェイクはもう存在しないからです。
この捉え方が見た目以上に重要な理由。この 4 つの guard は認可に隣接するパス上にあります。それぞれが、tool の呼び出しが実行されるか拒否されるかの分かれ目です。セキュリティの形をした問いに答える 4 つの呼び出し箇所を編集するというのは、チェックを弱めてしまう機会が 4 回あるということで、しかもそれがレビュアーが一度に頭に入れなければならない diff にばらまかれます。guard がすでに期待しているオブジェクトを組み立てるほうは、新しい関数 1 つで済み、既存のチェックはすべて正確に同じ意味を保ちます。
安全な方向に失敗するという性質もあります。私たちのバージョン判定が間違っていて、ステートレスなリクエストがレガシーだと誤読された場合、その帰結はそのリクエストのためにセッション行が作られることです。以前なら通らなかったものが通ることはありません。逆の設計——guard を緩めてバージョン文字列で門番をさせる——は、反対側に失敗します。
ステートレス化はテナンシーの面でも何も犠牲にしていません。そもそも identity はセッション行から来ていなかったからです。ステートレスセッションは、そのリクエストで提示された API キーまたは OAuth トークンから解決されたユーザーと組織を持ち、スコープは呼び出しごとに再導出されるのでキーの失効は即座に効き、tools/call はテナントが紐づいていないセッションを今も拒否します。盗めるものがひとつ減りました。リプレイされうる保存済みのセッション ID がありません。レガシー側のパスでは対応するチェックを残してあるので、セッション ID によってある資格情報が他人のセッションに保存された identity として振る舞うことはできません。
ルーティングは switch ひとつ
判断のすべては HTTP ハンドラーの中、JSON-RPC の body がパースされるより前にあります。
switch {
case mcp.IsModernProtocolVersion(requestedVersion):
// Stateless: the request describes itself, so nothing is looked up,
// nothing is written, and no Mcp-Session-Id comes back.
session = h.mcpServer.NewStatelessSession(auth.UserID, auth.OrganizationID, requestedVersion)
case sessionID != "":
// Handshake-based client with a session: look it up, and check it
// belongs to this credential.
default:
// Legacy client that has not handshaken yet: mint a session as before.
}
見落としやすい細部が 2 つあります。
IsModernProtocolVersion は "2026-07-28" との文字列比較です。リビジョンは YYYY-MM-DD なので、辞書順は時系列順であり、将来のリビジョンはハンドシェイクにフォールバックするのではなく、既定でステートレス側に着地します。ただしそこまで進むのは、私たちがそれをサポートしている場合だけです。認識できないバージョンは switch の前に拒否され、クライアントが再試行できるようにサポート済みの一覧がエラーの data に入ります。
そしてレスポンスヘッダー:
if !session.Stateless {
c.Set("Mcp-Session-Id", session.ID)
}
ステートレスセッションには ID がありません。空の Mcp-Session-Id を返すのは、存在しないものを再利用しろとクライアントに告げることであり、送らないことよりも悪いバグです。そして、自分が書いたのではないクライアントに対してでなければ表に出てこない類のバグでもあります。
changelog の残り、ちゃんと読む
バージョン switch が面白い判断です。リビジョンの残りは、見落としやすくて確認は安く済む小さな要件のリストなので、私たちは changelog を 1 行ずつ読み直しました。そのうち 4 つがこの回で入りました。
すべての result に resultType。 このリビジョンはこのフィールドを必須にしました。完了した回答には "complete"、multi round-trip パターンの中間結果には "input_required"。クライアント側には、古いサーバーでこれが欠けていたら "complete" として扱うよう指示されていますが、最終リビジョンを読むクライアントはこれを探します。私たちの result はいまこれを持っています。各 result 型がそれぞれフィールドを覚えるのではなく、各 result 型に埋め込まれた 1 つの Result struct から来ます。
リスト結果の ttlMs と cacheScope。 tools/list、prompts/list、resources/list、resources/read、resources/templates/list で必須になり、新しい CacheableResult インターフェース経由で提供されます。私たちはこの 5 つのうち 3 つをホストしていて、それらは 60000 と private を返します。60 秒は契約ではなくヒントです。エージェントが毎ターン 181 個の tool を再取得するのを止めるには十分長く、セッションの途中で登録された tool がすぐ現れる程度には短い。private は、受け入れたデフォルトではなく判断です。私たちが返すすべての結果は呼び出し元の組織にスコープされているので、共有される中間装置がそれをキャッシュして別のテナントに渡してよいことはありません。
2026-07-28 を宣言するクライアントからの DELETE /mcp。 DELETE はプロトコルレベルのセッションを終了させるものでしたが、プロトコルレベルのセッションはもうありません。仕様は 405 を返せと言っているので、モダンなクライアントが受け取るのはそれです。ハンドシェイクベースのクライアントには従来の挙動が残ります。
未実装のメソッドはいま、JSON-RPC の -32601 を載せた HTTP 404 を返します。 ステータスコードだけでは曖昧です。モダンなエンドポイントをそもそもホストしていないレガシーな HTTP+SSE サーバーもまた 404 を返すからです。両者を区別するのは JSON-RPC の body であり、仕様はクライアントがそれを使って initialize にフォールバックするか再試行するかを決めると明示しています。
そして、最初の一周で間違えたもの
私たちの unsupported-version エラーは -32600、JSON-RPC の汎用的な "invalid request" を返していました。それはまさにこのリビジョンまでは妥当でした。このリビジョンは JSON-RPC のサーバーエラー範囲を分割するエラーコード割り当てポリシーを定めています。-32000 から -32019 は実装定義のまま、-32020 から -32099 は仕様に属します。draft の期間に導入されたコードは、そのブロックへ番号を振り直されました。HeaderMismatch は -32001 → -32020、MissingRequiredClientCapability は -32003 → -32021、UnsupportedProtocolVersion は -32004 → -32022 です。
最終リビジョンに対して書かれたクライアントは -32022 を探します。私たちが送っていたものは認識されなかったはずで、その失敗モードはこのリビジョン全体が避けようとしているまさにそれです。クライアントは「バージョンが違う、私が話せるのはこれだ」と「あなたのリクエストは不正だった」を区別できず、だから再試行の手がかりを持てません。
これを捕まえたものは、changelog をもう一度読んだこと以外にありませんでした。それはこの記事自身の主張を私たちに向け直したものです。この番号の振り直しは minor changes の 12 番目、OpenTelemetry の _meta キーと JSON Schema のキーワードに関する項目のあとにあります。流し読みしてしまう類の行です。
リネームのひとつは何のコストにもなりませんでした。resource-not-found は JSON-RPC の "invalid params" に合わせて -32002 から -32602 に移りましたが、resources/read は未知の URI に対してすでに -32602 を返していました。
私たちが計測したもの
これらはすべて、このビルドで動いているコンテナに対して、本物の API キーで計測しました。Postgres の行を直接数えられるようにするためです。
| テスト | 結果 |
|---|---|
MCP-Protocol-Version: 2026-07-28 付き・ハンドシェイクなしの tools/list |
181 tool |
そのレスポンスで返された Mcp-Session-Id |
なし |
tools/list と server/discover の resultType |
complete |
tools/list の ttlMs / cacheScope |
60000 / private |
server/discover |
["2026-07-28", "2025-03-26"] |
| 未サポートのバージョンを宣言 | -32022、エラーの data にサポート一覧 |
2026-07-28 を宣言するクライアントからの DELETE /mcp |
405 |
| 未知のメソッド | -32601 を載せた 404 |
レガシーの initialize ハンドシェイク |
いまも動作 |
session id 付きのレガシーな tools/list |
181 tool |
10 回のステートレスリクエストが作った mcp_sessions の行 |
0 |
3 回のレガシーリクエストが作った mcp_sessions の行 |
3 |
見るべきは最後の 2 行です。10 リクエスト、行はゼロ。3 回のレガシーリクエストはいずれもセッション ID なしで届いたので、それぞれが 1 行ずつ作りました。行儀のよいハンドシェイクベースのクライアントは ID を再利用するので、呼び出しごとではなくセッションの寿命に対して 1 行です。要点はゼロのほうです。ステートレスなパスには、書くものも、期限切れにするものも、クリーンアップの goroutine が見つけるものも何もありません。
その表の最初の行を生んだリクエストを、公開エンドポイントに向けたものがこれです。
curl -s https://api.archyl.com/mcp \
-H "X-API-Key: $ARCHYL_API_KEY" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
initialize なし。セッションなし。181 個の tool。
deprecated な transport が隠していたバグ
新しい transport を監査したことで古いほうを見ることになり、古いほうには本物のバグがありました。
2024-11-05 の HTTP+SSE transport は、ひとつの会話を 2 本の接続に分けます。クライアントは GET で長命なストリームを開き、サーバーの最初のイベントがどこに POST すべきかを伝え、そこから先はすべてのメッセージが POST で出ていき、すべてのレスポンスがストリームで返ってきます。この 2 本の接続が同じインスタンスに届く必要はありません。
私たちのものは、届くと仮定していました。レスポンスチャネルはサーバー struct 上のあの map[string]chan *JSONRPCMessage にあったので、インスタンス B が処理した POST は、インスタンス B に存在していて、インスタンス B の誰も読んでいないチャネルにレスポンスを書き込んでいました。ストリームはインスタンス A にありました。クライアントは待ち続けました。
これを設計上の匂い以上に悪いものにしているのは、何もログに出なかったことです。エラーなし、警告なし、失敗したリクエストもなし。POST は 202 Accepted を返し、それは本当のことで、メッセージは確かに受理されていて、そして答えはどこにも行きませんでした。外から見ると、遅い tool 呼び出しと区別がつきません。これは水平スケールされたデプロイでしか起きず、そこはまさに、手作業で再現する気力が最も湧かない場所です。
その map はいま streamrouter.go にある Redis pub/sub のルーターになりました。このプロセスが保持しているストリーム宛のレスポンスは直接配送され、往復することはありません。別の場所で保持されているストリーム宛のレスポンスは mcp:stream:<sessionID> に publish され、そのストリームを保持しているインスタンスがそれを subscribe しています。どのインスタンスでも POST を受けられます。セッションアフィニティは不要ですし、誰も書いた記憶のないロードバランサー設定の中で sticky session ルールを保守する必要もありません。
これについて言っておく価値のあることが 2 つあります。ルーターは依存物だからです。
Redis がいまこのパス上にあります。起動時に到達できない場合、ルーターは起動を拒否するのではなくローカル配送のみにフォールバックし、警告をログに出します。単一インスタンスならローカルのみで正しく、2 つ目ができて初めて誤りになるからです。この失敗はわざと騒がしくしてあります。代替は、いま取り除いたばかりの沈黙したハングだからです。これをデプロイするなら、探すべき起動時の行は MCP stream router: Redis connected です。それがないこと自体がすべてを物語ります。
そしてルーターが直すのはルーティングであって、場所ではありません。ストリームは依然としてひとつのプロセスが保持する接続です。Redis はレスポンスをそのプロセスまで運ぶだけで、ストリームを動かしはしません。その部分は還元不能です。開かれた接続は、どんなプロトコルでも、開かれた場所に存在します。
私たちがやっていないこと
告知は普通ここで終わります。はっきり言っておく価値のあることが 2 つあります。どちらもあなたが確認できるからです。
Archyl は、重要なパスの上で 2026-07-28 を話します。エンドツーエンドでステートレスなわけではありません。
ステートレスなパスは本当にステートレスです。セッションの参照なし、セッションの書き込みなし、Mcp-Session-Id なし、リクエストをプロセスに固定するものもなし。このパスは素の round-robin ロードバランサーの背後に置けます。
私たちのサーバーは、より古い HTTP+SSE transport にも /sse で応答し続けていますが、ドキュメントに載せるのはやめました。かつてその URL を載せていたページは、いまではどれも /mcp を載せています。設定してくださいとお願いするエンドポイントは、それひとつだけです。
理由は、いま追加したばかりの依存物です。ルーターがアフィニティ要件をなくすのは、Redis に到達できるところだけです。到達できないところでは、配送はローカル配送のみにフォールバックします。それは 1 インスタンスなら正しく、2 つになると静かに誤りになります。私たちの本番環境は今日 Redis を動かしていないので、動いているのはそのフォールバックのほうです。正しさがインスタンス数に依存しない transport のほうへ全員を案内したい。依存するほうを公開するよりも、です。
/sse について、どこで動いていようと変わらず本当なのは、ストリームは単一のプロセスが保持する接続であり、その寿命のあいだ Postgres にセッション行が存在するということです。アフィニティ要件をなくすことは、状態をなくすことと同じではありません。この transport を廃止する日付は告知しません。
ただし、その transport の時計は私たちのものではなく、思っていたより短く進んでいます。HTTP+SSE は 2025-03-26 リビジョン以来 deprecated です。2026-07-28 がやったのは、それを新しいフィーチャーライフサイクルポリシーのもとで Deprecated として分類し直したことです。このポリシーは、deprecation から削除対象になるまでに最低 12 か月のウィンドウを設けています。Roots、Sampling、Logging が得るのがそれで、最短の削除は "the first revision released on or after 2027-07-28" ——2027 年 7 月 28 日以降に最初にリリースされるリビジョン——です。HTTP+SSE には 12 か月は与えられません。ポリシーが存在するよりずっと前からすでに deprecated だったからです。deprecated 機能のレジストリは、その最短の削除時期を "Three months after SEP-2596 reaches Final" ——SEP-2596 が Final に到達してから 3 か月後——としています。削除は依然としてリリース準備時に Core Maintainer が下す判断であり、もっと後になる可能性もありますが、HTTP+SSE をどこかで動かしているなら、読むべきはその行です。
私たちが実装したのはこのリビジョンの形であって、全部ではありません。 出荷されるのは、バージョンネゴシエーション、ステートレスなリクエストパス、server/discover、正しいコードを持つ unsupported-version エラー、resultType、キャッシュのヒント、transport が要求する 405 と 404、そしてまだ必要とするクライアントのためのハンドシェイクパスです。以下はありません。
Mcp-MethodとMcp-Nameのリクエストヘッダー、およびそれに伴う検証。 これが最大の欠落です。このリビジョンは、POST が自分のmethodと、params.nameまたはparams.uriをヘッダーに写すことを要求し、サーバーが不一致を400と-32020 HeaderMismatchで拒否することを要求します。理由は整頓のためではありません。仕様自身の言葉では、これは "prevents potential security vulnerabilities when different components in the network rely on different sources of truth (e.g., a load balancer routing on the header value while the MCP server executes based on the body value)" ——ネットワーク内の異なるコンポーネントが異なる真実の source に依拠するとき(たとえばロードバランサーはヘッダーの値でルーティングし、MCP サーバーは body の値に基づいて実行する場合)に生じうるセキュリティ脆弱性を防ぐ——ものです。同じ規則はMCP-Protocol-Versionにも及び、その値はリクエストの_metaにある値と一致しなければなりません(MUST)。私たちはバージョンをヘッダーからだけ読み、_metaを見ることは一度もないので、拒否することを要求されている不一致を検出できません。ヘッダーは body がパースされる前に手に入る、だからそこで読んでいる。それはクロスチェックを飛ばしてよい理由にはなりません。subscriptions/listen、およびInputRequiredResultを伴う Multi Round-Trip Requests。 修正というより機能まるごとです。私たちはresources/subscribeを一度も実装していないので、それを置き換えるメソッドは今日のところ何のコストにもなっていません。Originヘッダーの検証。 仕様はこれを MUST とし、不正な origin には403を返すこと、DNS リバインディングへの防御としています。私たちは/mcpでこれをやっていません。- capability の
extensions、およびtools/listからの決定的な順序。 後者は SHOULD で、クライアント側キャッシュと LLM の prompt cache のヒット率を狙ったものです。私たちのものは Go の map から出てくるので、順序はその日にその map が返すもの次第です。 - Dynamic Client Registration。 このリビジョンはこれを Client ID Metadata Documents の採用とともに deprecated にしていますが、私たちは
POST /registerを今も公開しています。置き換えをサポートしない authorization server のために残してあるので、これは破壊ではなく移行であり、Roots、Sampling、Logging と同じ 12 か月の時計の上にあります。 server/discoverは/mcp上の他のすべてと同じ API キーの背後にあります。 匿名の呼び出し元には応答しません。これは意図的な選択ですが、サーバーを発見しようとしているクライアントが期待するものではありません。
残りは作業であり、済んでいるのではなくリストに載っています。
自分の MCP サーバーを運用しているなら
自分のサーバーに対して走らせる価値のあるチェック:
MCP-Protocol-Version: 2026-07-28を付け、ハンドシェイクなしでtools/listを送る。"session not initialized" が返ってくるなら、あなたのサーバーは現行リビジョンを提供していません。server/discoverを呼ぶ。いまや必須です。method-not-found が返るなら、それが最も小さく塞げる穴です。- サポートしていないバージョンを宣言する。エラーがサポート済みバージョンの一覧を運んでいること、そしてそのコードが汎用のものではなく
-32022であることを確認する。これは私たちが落としたチェックです。 - 任意の result を読む。そのすべてに
resultTypeが要り、リスト結果にはさらにttlMsとcacheScopeが要ります。 - ステートレスなリクエストで
Mcp-Session-Idに何を返しているか見る。空は、無いことより悪い。 - 書き込みを数える。ステートレスなリクエストを 10 回送り、セッションストアに何か着地したか確認する。その数字が、移行が成功したかどうかへの正直な答えです。
- HTTP+SSE をまだ提供していて、インスタンスを 2 つ以上動かしているなら、ストリームを別のインスタンスが保持している状態で片方に POST する。ログに何も出ないままハングするクライアントが、私たちが抱えていたバグです。そのうえで、上の deprecation レジストリの行を読む。
「新しいバージョンヘッダーを受け付ける」と「実際にステートレス」のあいだの隔たりに作業のほとんどがあり、自分がどちら側にいるかを教えてくれるのはステップ 6 だけです。
つないでみる
エンドポイントは変わっておらず、どちらのリビジョンでも動きます。Claude Code なら、プロジェクトルートに .mcp.json を置きます。
{
"mcpServers": {
"archyl": {
"type": "http",
"url": "https://api.archyl.com/mcp",
"headers": {
"X-API-Key": "YOUR_API_KEY"
}
}
}
}
リビジョンを選ぶのはあなたのクライアントです。2026-07-28 を話すなら、ハンドシェイクなし・セッションなしで提供されます。話さないなら、そのクライアントにとっては何も変わりません。
Claude Code、Cursor、VS Code、Codex、Warp、Windsurf、Antigravity 向けの完全なセットアップと、エージェントが何を変更できるかを決めるスコープについては、MCP サーバーのドキュメントにあります。