Обслуживание клиентов старого поколения
Машинный перевод
Эта страница переведена с английской документации автоматически, и основной версией остаётся английская страница. Если что-то читается неправильно, на странице Переводы объясняется, как об этом сообщить.
У MCP два поколения протокола: поколение рукопожатия initialize — до версии спецификации 2025-11-25 включительно — и современное поколение, 2026-07-28. Самому этому разделению посвящена страница Версии протокола.
Эта страница — о серверной стороне этого разделения, и ответ умещается в одно предложение: приложение streamable_http_app(), которое вы уже развёртываете, обслуживает оба поколения.
SDK маршрутизирует каждый запрос по его заголовку MCP-Protocol-Version. Запрос, в котором указана 2026-07-28, попадает в современный обработчик. Запрос с версией поколения рукопожатия или вовсе без заголовка (именно так приходит initialize от клиента до 2026 года) уходит в транспорт, которого ждут такие клиенты: рукопожатие initialize, сессии и всё остальное. Это происходит для каждого запроса отдельно, до вашего кода, в одном и том же приложении.
Так что клиент старого поколения — не то, ради чего вы что-то пишете. Это то, что само подключается к уже написанному серверу. Настраивать ничего не нужно.
Note
Буквально ничего. Нет параметра legacy=, нет списка разрешённых версий, нет способа
отклонить или отключить поколение: ни в streamable_http_app(), ни в run(), ни в менеджере
сессий. Оба поколения включены всегда. Ближе всего к переключателю поколений в этой сигнатуре
параметр stateless_http — ему и посвящена бо́льшая часть страницы.
Один обработчик, оба поколения
Вот инструмент, которому нужно кое-что спросить у пользователя, и клиенты обоих поколений, которые его вызывают:
from typing import Annotated
from pydantic import BaseModel
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.server import MCPServer
from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve
from mcp.types import ElicitRequestParams, ElicitResult
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."
async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="accept", content={"copies": 2})
async def main() -> None:
async with (
Client(mcp, mode="legacy", elicitation_callback=answer) as legacy,
Client(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)
Инструменту reserve нужно одно, чего модель не сообщила: сколько экземпляров. Annotated[..., Resolve(ask_quantity)] — так инструмент это объявляет (подробнее — на странице Зависимости). Ничто в reserve не называет версию, не проверяет возможность и не ветвится.
Оба клиента открыты одновременно, на одном и том же объекте mcp. mode="legacy" выполняет рукопожатие initialize — ровно такое подключение открывает клиент до 2026 года. Второй клиент берёт значение по умолчанию и оказывается на 2026-07-28.
2025-11-25 {'result': "Reserved 2 of 'Dune'."}
2026-07-28 {'result': "Reserved 2 of 'Dune'."}
Тот же сервер, тот же обработчик, тот же ответ. Вот и весь механизм.
Стоит задержаться на том, как это работает, потому что один и тот же вопрос двум клиентам задали по двум совершенно разным каналам. У подключения 2026-07-28 нет канала, по которому сервер мог бы отправить запрос, поэтому Resolve вернул вопрос внутри результата инструмента, а клиент повторил вызов уже с ответом (Многораундовые запросы (multi-round-trip)). У подключения 2025-11-25 ничего подобного нет; там Resolve отправил настоящий запрос elicitation/create прямо посреди вызова и дождался ответа. Ни того ни другого вы не писали. Resolve читает согласованную версию подключения и выбирает сам; тело инструмента в обоих случаях получает AcceptedElicitation.
Tip
Именно эта переносимость между поколениями — причина, почему строить стоит на Resolve.
Его старший родственник ctx.elicit() (Элицитация (elicitation))
умеет отправлять только elicitation/create, так что работает только на подключении старого
поколения. На подключении 2026-07-28 вызов завершается ошибкой. Если какой-то инструмент всё
ещё им пользуется, исправление — то, что показано выше, а не проверка версии.
Во что обходится сессия старого поколения
Маршрутизация бесплатна. Сессия — нет.
Подключение 2026-07-28 бессессионное: каждый запрос самостоятелен, и современный обработчик никогда не выдаёт Mcp-Session-Id. Подключение старого поколения — полная противоположность. Как только клиент до 2026 года отправляет initialize, SDK создаёт Mcp-Session-Id, возвращает его в заголовке ответа и хранит за ним живую запись, которую будут находить последующие запросы клиента: согласованная версия, открытые потоки, фоновая задача, ведущая сессию.
Эта запись — обычный dict внутри процесса. Распределённого хранилища сессий нет, и подключить своё невозможно.
На одном рабочем процессе это незаметно. На двух — в этом вся проблема: запрос с Mcp-Session-Id, попавший на рабочий процесс, который этот идентификатор не создавал, ничего в словаре не находит, и в ответ приходит 404 (Session not found), а не результат инструмента. Поэтому, как только рабочих процессов больше одного, клиентам старого поколения нужна липкая маршрутизация (sticky routing): каждый запрос сессии должен попадать в тот процесс, который её начал. Современным клиентам это не нужно никогда: у них нет сессии, к которой можно было бы привязаться. О привязке и обо всём остальном, что касается запуска нескольких экземпляров, — на странице Развёртывание и масштабирование.
Warning
event_store= выглядит как решение, но это не оно. Это возобновляемость (повторная
отправка пропущенных SSE-событий клиенту, который переподключается к той же сессии), а не
хранилище сессий. Сессию доступной из другого процесса он не делает никогда.
Единственный переключатель: stateless_http
Если привязка — цена, которую вы платить не готовы, изменить можно ровно одно.
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)
Это сервер из начала страницы плюс один именованный аргумент. С stateless_http=True ветка старого поколения вместо этого создаёт одноразовую сессию на каждый запрос: Mcp-Session-Id не выдаётся, между запросами ничего не запоминается, так что любой рабочий процесс может обслужить любой запрос, а балансировщик нагрузки волен делать что угодно.
Две вещи о нём важнее того, что он делает.
Он затрагивает только ветку старого поколения. Запросы маршрутизируются по заголовку версии до того, как читается stateless_http, так что современный путь его не видит вовсе. Подключение 2026-07-28 и так бессессионное и ведёт себя совершенно одинаково при любом значении.
Он стоит обоих каналов от сервера к клиенту на этой ветке. У сессии, живущей один POST, нет потока, по которому сервер мог бы отправить запрос, и нет отдельного потока, по которому он мог бы отправлять уведомления. Каждый запрос по инициативе сервера выбрасывает NoBackChannelError: ctx.elicit(), отправленные на покой вызовы сэмплирования (sampling) и корневых каталогов (roots) (Устаревшие возможности) и — да — Resolve, задающий свой вопрос клиенту старого поколения. Уведомления не получают даже ошибки: они молча отбрасываются.
Note
json_response=True — не тот переключатель, но половину той же цены он берёт с каждой
сессии старого поколения: у POST, на который отвечают одним JSON-телом, нет потока для
канала, привязанного к запросу, поэтому ctx.elicit() посреди запроса выбрасывает ту же
NoBackChannelError, а уведомления, связанные с запросом, отбрасываются. Отдельный поток
сессии не затронут: не связанные с запросом уведомления по-прежнему приходят.
Check
Сделайте заведомо неправильно. reserve — тот самый инструмент, который только что обслужил
оба клиента. Разверните его с stateless_http=True, подключите те же два клиента по HTTP и
вызовите его из каждого.
Современный клиент по-прежнему получает 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 существует именно для того, чтобы это было не вашей заботой: вы только что видели, как один инструмент обслужил оба.
Остаётся ровно одно — уведомления об изменениях, потому что два поколения слушают разные каналы:
- Клиент
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 четыре вида уведомлений об изменениях ходят только по потокам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"
Две строки, никакого if, никакой проверки версии — и готово. Это полный список того, что обработчик делает иначе из-за существования клиентов старого поколения.
Итоги
- Одно приложение
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его никогда не затрагивает. - Код обработчика ветвится по поколению ровно в одном месте: уведомления об изменениях.
ctx.notify_*доходит до клиентовsubscriptions/listen;ctx.session.send_*— до сессий старого поколения. Вызывайте оба. - Всё остальное (включая запрос ввода у пользователя через
Resolve) переносимо между поколениями по построению. Напишите современный вариант один раз.