レガシークライアントへの対応
MCP のプロトコルには 2 つの世代があります。仕様バージョン 2025-11-25 までの initialize ハンドシェイクの世代と、モダンな世代である 2026-07-28 です。この区分そのものについては プロトコルバージョン のページで説明しています。
このページが扱うのはその区分のサーバー側ですが、答えは 1 文で済みます。すでにデプロイしている streamable_http_app() が両方に対応します。
SDK はすべてのリクエストを MCP-Protocol-Version ヘッダーで振り分けます。2026-07-28 を指定したリクエストはモダンなハンドラーに渡ります。ハンドシェイク世代のバージョンを指定したリクエスト、またはヘッダーをまったく持たないリクエスト(2026 年より前のクライアントの initialize はこの形で届きます)は、それらのクライアントが期待するトランスポートに渡ります。initialize ハンドシェイクもセッションも、すべてそろっています。この振り分けはリクエストごとに、コードが動く前に、1 つのアプリ上で行われます。
つまり、レガシークライアントは「そのために」何かを作る対象ではありません。すでに書いたサーバー「に」接続してくる存在です。設定することは何もありません。
Note
文字どおり、何もありません。legacy= オプションも、バージョンの許可リストも、ある世代を拒否したり無効にしたりする手段もありません。streamable_http_app() にも、run() にも、セッションマネージャーにもありません。両方の世代が常に有効です。そのシグネチャの中で世代ごとのスイッチに最も近いものは stateless_http で、このページの大半はその話です。
1 つのハンドラーで両方の世代
ユーザーに何かを尋ねる必要があるツールを示します。
from typing import Annotated
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve
mcp = MCPServer("Bookshop")
class Quantity(BaseModel):
copies: int
async def ask_quantity() -> Elicit[Quantity]:
"""Resolver: ask the user how many copies to put aside."""
return Elicit("How many copies?", Quantity)
@mcp.tool()
async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]) -> str:
"""Reserve copies of a book, asking the user how many."""
if isinstance(quantity, AcceptedElicitation):
return f"Reserved {quantity.data.copies} of {title!r}."
return "Nothing reserved."
reserve には、モデルが渡してこなかったものが 1 つ必要です。何冊予約するかです。ツールはそれを Annotated[..., Resolve(ask_quantity)] で宣言します(詳しくは 依存関係 を参照してください)。reserve の中には、バージョンを指定する箇所も、ケイパビリティを確認する箇所も、分岐する箇所もありません。
これを HTTP で公開します。続いて、両方の世代のクライアントがそれを呼び出す様子です。
uv run mcp run server.py --transport streamable-http
import anyio
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.types import ElicitRequestParams, ElicitResult
async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="accept", content={"copies": 2})
async def main() -> None:
async with (
Client("http://localhost:8000/mcp", mode="legacy", elicitation_callback=answer) as legacy,
Client("http://localhost:8000/mcp", elicitation_callback=answer) as modern,
):
for client in (legacy, modern):
result = await client.call_tool("reserve", {"title": "Dune"})
print(client.protocol_version, result.structured_content)
if __name__ == "__main__":
anyio.run(main)
2 つのクライアントは、動作中の同じサーバーに対して同時に開かれています。mode="legacy" は initialize ハンドシェイクを実行します。2026 年より前のクライアントが開くのとまったく同じ接続です。もう一方はデフォルトのままで、2026-07-28 になります。別のターミナルから python client.py を実行してください。
2025-11-25 {'result': "Reserved 2 of 'Dune'."}
2026-07-28 {'result': "Reserved 2 of 'Dune'."}
同じサーバー、同じハンドラー、同じ答えです。この機能はこれですべてです。
「どのように」実現されたかは、少し立ち止まって見る価値があります。2 つのクライアントは、まったく異なる 2 つの通信路を通じて同じ質問を受けたからです。2026-07-28 の接続にはサーバーがリクエストを送るためのチャネルがないため、Resolve は質問をツール結果の中に入れて返し、クライアントは答えを添えて呼び出しを再試行しました(マルチラウンドトリップ(multi-round-trip)リクエスト を参照してください)。2025-11-25 の接続にはそうした仕組みはありません。そこでは Resolve が呼び出しの途中で実際の elicitation/create リクエストを送り、待機しました。どちらも自分で書いてはいません。Resolve が接続でネゴシエートされたバージョンを読み取って選びます。どちらの場合も、ツール本体が目にするのは AcceptedElicitation です。
Tip
この世代間の可搬性こそが、Resolve が土台とすべき API である理由です。古い兄弟分の ctx.elicit()(エリシテーション(elicitation))は elicitation/create を送ることしかしないため、レガシー接続でしか動きません。2026-07-28 の接続では呼び出しは失敗します。ツールがまだこれを使っている場合、直し方は上で見たとおりの方法であって、バージョンチェックではありません。
レガシーセッションのコスト
振り分けにコストはかかりません。セッションにはかかります。
2026-07-28 の接続はセッションレスです。各リクエストは独立しており、モダンなハンドラーが Mcp-Session-Id を発行することはありません。レガシー接続はその逆です。2026 年より前のクライアントが initialize を送った瞬間に、SDK は Mcp-Session-Id を発行し、レスポンスヘッダーで返し、その裏に生きたレコードを保持します。クライアントの後続リクエストが見つけられるように、ネゴシエートされたバージョン、開いているストリーム、セッションを駆動するバックグラウンドタスクが記録されます。
そのレコードはプロセス内の単なる dict です。分散セッションストアはなく、差し込む手段もありません。
ワーカーが 1 つなら、これは表に出ません。2 つになると、これが問題のすべてになります。Mcp-Session-Id を持つリクエストが、それを発行していないワーカーに届くと、その辞書には何も見つからず、返るのはツール結果ではなく 404(Session not found)です。したがって、複数のワーカーを動かした瞬間から、レガシークライアントにはスティッキールーティングが必要です。セッション内のすべてのリクエストが、そのセッションを開始したプロセスに届かなければなりません。モダンなクライアントにはその必要はありません。スティッキーにすべきセッションがないからです。スティッキー性をはじめ、複数台で動かす際のあらゆることは デプロイとスケール で扱っています。
Warning
event_store= は解決策に見えますが、そうではありません。これは再開可能性(「同じ」セッションに再接続するクライアントに、取りこぼした SSE イベントを再送する機能)であって、セッションストアではありません。別のプロセスからセッションに到達できるようにはしません。
セッションの有効期間と上限
レガシーセッションは永遠には生き続けませんし、1 つのプロセスが無制限にセッションを抱えることもありません。これを制御する設定が 2 つあります。どちらも run()、streamable_http_app()、Server.streamable_http_app() のキーワード引数です。モダンな(2026-07-28 の)接続と stateless_http=True にはセッションがないため、どちらの設定も適用されません。
| 設定 | デフォルト | 動作 | クライアントから見えるもの | 無効にするには |
|---|---|---|---|---|
session_idle_timeout |
1800(30 分) |
処理中のものが何もない状態がその時間続いたセッションを閉じます。 | 404 Session not found。もう一度 initialize する必要があります。 |
None |
max_sessions |
10_000 |
その数を超えて新しいセッションを開くことを拒否します。既存のセッションには手を付けず、何も追い出しません。 | 503 Too many open sessions、JSON-RPC コードは -32603 です。 |
None |
「処理中」と見なされるものは次のとおりです。
- 開いている
GETストリーム。SDK のクライアントは 1 本を開いたままにするため、接続中のクライアントのセッションが期限切れになることはありません。 - まだ応答中のリクエスト。タイムアウトより長く動くツール呼び出しが中断されることはなく、カウントダウンはその呼び出しが終わってから始まります。
- それ以外にはありません。リクエストとリクエストの間は時計が進みます。セッション上のどんなリクエストでも時計はリセットされ、
pingも例外ではありません。一度期限切れになったセッションを復活させる手段はありません。
DELETE でセッションを終了したクライアントは、そのセッションを即座に解放します。最初のリクエストが拒否されたクライアントも同様です。
mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000)
どちらの出来事もサーバーのログに残ります。期限切れは INFO レベルの Session <id> idle timeout です。セッションを開くのを拒否した場合は WARNING レベルの Refusing to open a new session: <n> sessions are already open です。
これらの上限はプロセスごとです。ワーカーが 4 つなら上限は max_sessions の 4 倍で、各ワーカーは自分のセッションだけを期限切れにします。
唯一のスイッチ:stateless_http
スティッキー性というコストを払いたくないなら、変更できるものがちょうど 1 つだけあります。
from typing import Annotated
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve
mcp = MCPServer("Bookshop")
class Quantity(BaseModel):
copies: int
async def ask_quantity() -> Elicit[Quantity]:
"""Resolver: ask the user how many copies to put aside."""
return Elicit("How many copies?", Quantity)
@mcp.tool()
async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]) -> str:
"""Reserve copies of a book, asking the user how many."""
if isinstance(quantity, AcceptedElicitation):
return f"Reserved {quantity.data.copies} of {title!r}."
return "Nothing reserved."
app = mcp.streamable_http_app(stateless_http=True)
これはページ冒頭のサーバーに、キーワードを 1 つ加えただけのものです。stateless_http=True にすると、レガシー経路はリクエストごとの使い捨てセッションを作るようになります。Mcp-Session-Id は発行されず、リクエスト間で何も記憶されないため、どのワーカーもどのリクエストにも応答でき、ロードバランサーは好きなように振り分けられます。
これについては、何をするかよりも重要なことが 2 つあります。
影響するのはレガシー経路だけです。 リクエストは、stateless_http が読まれる「前に」バージョンヘッダーで振り分けられるため、モダンな経路がこの設定を目にすることはありません。2026-07-28 の接続はもともとセッションレスで、どちらの値でもまったく同じです。
その経路では、サーバーからクライアントへの 2 つのチャネルが両方とも失われます。 1 回の POST の間しか存在しないセッションには、サーバーがリクエストを送り込むストリームも、通知を送り込むスタンドアロンストリームもありません。サーバー起点のリクエストはすべて NoBackChannelError を送出します。ctx.elicit()、非推奨となったサンプリングとルート(roots)の呼び出し(非推奨の機能)、そしてもちろん、「レガシー」クライアントに質問する Resolve もです。通知はエラーにすらなりません。黙って捨てられます。
Note
json_response=True はそのスイッチではありませんが、「すべての」レガシーセッションで同じコストの半分を負います。1 つの JSON ボディで応答される POST にはリクエストスコープのチャネル用のストリームがないため、リクエスト途中の ctx.elicit() は同じ NoBackChannelError を送出し、そのリクエストに結び付いた通知は捨てられます。セッションのスタンドアロンストリームには影響しません。無関係な通知は引き続き届きます。
Check
あえて間違ったことをしてみましょう。reserve は、先ほど両方のクライアントに応答したそのツールです。これを stateless_http=True でデプロイし、同じ 2 つのクライアントを接続して、それぞれから呼び出してください。
モダンなクライアントには引き続き Reserved 2 of 'Dune'. が返ります。モダンな経路は変わっていません。
レガシークライアントの呼び出しは、モデルが読める is_error の結果としては返ってきません。リクエスト全体が、トップレベルのプロトコルエラーとして失敗します。
mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.
Resolve は助けにはなりませんでした。2025-11-25 の接続では elicitation/create を送る「しかない」のですが、そのために必要なチャネルこそが、stateless_http=True で手放したものです。世代間で可搬なコードは、バックチャネル(back-channel)を必要としないコードではありません。
つまりこれは本物のトレードオフで、レガシー経路にだけ存在します。セッションありでスティッキーか、ステートレスで一方向か。 ツールがクライアントに呼び返すことが決してないなら、stateless_http=True にコストはないので採用すべきです。呼び返すなら、セッションを維持し、ルーティングもスティッキーのままにしてください。
コードが実際に分岐する場所
ほぼどこにもありません。
ツール、リソース、プロンプト、構造化出力、進捗、エラー。これらはどれも、どの世代から呼ばれたかを気にしません。initialize ハンドシェイク、Mcp-Session-Id、スタンドアロンストリーム、セッションを終わらせる DELETE。これらはすべて SDK の管轄で、ハンドラーが目にすることはありません。対話的な入力は、世代によって通信路上で本当に違いが出る唯一の場所ですが、それを気にしなくて済むように Resolve があります。1 つのツールが両方に応答するのを見たばかりです。
残るのはちょうど 1 つ、変更通知です。2 つの世代が別々の経路で待ち受けているからです。
2026-07-28のクライアントはsubscriptions/listenストリームを開き、サブスクリプションバスを読みます。ctx.notify_resource_updated()(およびnotify_tools_changed()、notify_prompts_changed()、notify_resources_changed())はそこに、そしてそこに「だけ」発行します。詳しくは サブスクリプション を参照してください。- レガシークライアントは、自分のセッションが開いたままにしているスタンドアロンストリームを読みます。
ctx.session.send_resource_updated()(およびsend_tool_list_changed()などの仲間)は、リクエストを運んだ「接続」に書き込みます。レガシーセッションの場合、それはスタンドアロンストリームです。モダンな接続にはその置き場所がありません。HTTP ではそうしたチャネルがなく、stdio では 4 種類の変更通知はsubscriptions/listenストリームにしか乗らないため、モダンな接続ではその通知は黙って捨てられます。
HTTP では、どちらの呼び出しももう一方の世代のクライアントには届きません。全員に知らせるには、両方を呼び出します。
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
mcp = MCPServer("Bookshop")
STOCK = {"Dune": 3}
@mcp.resource("stock://{title}")
def stock(title: str) -> str:
"""How many copies of one book are on the shelf."""
return f"{STOCK[title]} in stock"
@mcp.tool()
async def restock(title: str, copies: int, ctx: Context) -> str:
"""Put copies of a book back on the shelf."""
STOCK[title] = STOCK.get(title, 0) + copies
await ctx.notify_resource_updated(f"stock://{title}")
await ctx.session.send_resource_updated(f"stock://{title}")
return f"{STOCK[title]} in stock"
2 行だけで、if もバージョンチェックもなく、これで完了です。レガシークライアントが存在するためにハンドラーが違うことをする箇所は、これがすべてです。
まとめ
- 1 つの
streamable_http_app()が両方のプロトコル世代に対応します。SDK は各リクエストをMCP-Protocol-Versionヘッダーで振り分けます。設定するものはなく、探すべき世代のスイッチもありません。 - レガシークライアントにはセッションというコストがかかります。裏に分散ストアを持たない、プロセス内の
Mcp-Session-Idレコードです。ワーカーが複数ならスティッキールーティングが必要で、さもなければ間違ったワーカーが404 Session not foundを返します。複数ワーカーについて詳しくは デプロイとスケール を参照してください。 stateless_http=Trueが唯一のスイッチで、レガシー経路にだけ効きます。レガシークライアントのロードバランシングが自由になる代わりに、その経路ではサーバーからクライアントへのチャネルが両方とも失われます。サーバー起点のリクエストはNoBackChannelErrorを送出し(クライアント側ではis_errorの結果ではなくトップレベルのエラーになります)、通知は捨てられます。2026-07-28の接続はどちらにしてもセッションレスです。stateless_httpがこれに影響することはありません。- ハンドラーのコードが世代で分岐するのはちょうど 1 か所、変更通知です。
ctx.notify_*はsubscriptions/listenのクライアントに届き、ctx.session.send_*はレガシーセッションに届きます。両方を呼び出してください。 - それ以外はすべて(
Resolve経由でユーザーに入力を求めることも含めて)、仕組みのうえで世代間で可搬です。モダンなやり方で一度書けば済みます。