Обслуживание клиентов старого поколения
Машинный перевод
Эта страница переведена с английской документации автоматически, и основной версией остаётся английская страница. Если что-то читается неправильно, на странице Переводы объясняется, как об этом сообщить.
У 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.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 нужно одно, чего модель не сообщила: сколько экземпляров. 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)
Оба клиента открыты одновременно, к одному и тому же работающему серверу. 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'."}
Тот же сервер, тот же обработчик, тот же ответ. Вот и весь механизм.
Стоит задержаться на том, как это работает, потому что один и тот же вопрос двум клиентам задали по двум совершенно разным каналам. У подключения 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-событий клиенту, который переподключается к той же сессии), а не
хранилище сессий. Сессию доступной из другого процесса он не делает никогда.
Время жизни и лимиты сессий
Сессия старого поколения не живёт вечно, и один процесс не держит их неограниченное количество.
За это отвечают две настройки. Обе — именованные аргументы 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 держат такой поток открытым, поэтому сессия подключённого клиента никогда не истекает. - Запрос, на который ещё готовится ответ. Вызов инструмента, работающий дольше тайм-аута, не прерывается, а обратный отсчёт начинается только после его завершения.
- Больше ничего. Между запросами часы идут. Любой запрос в сессии запускает их заново,
включая
ping. Истёкшую сессию уже ничто не оживит.
Клиент, завершающий сессию запросом DELETE, освобождает её сразу. То же происходит
с клиентом, чей открывающий запрос был отклонён.
mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000)
Оба события попадают в лог сервера. Истечение — Session <id> idle timeout на уровне INFO.
Отказ в открытии — Refusing to open a new session: <n> sessions are already open на уровне WARNING.
Лимиты действуют на процесс. При четырёх рабочих процессах потолок — четыре раза по max_sessions,
и каждый рабочий процесс сам отсчитывает время жизни своих сессий.
Единственный переключатель: 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, подключите те же два клиента и
вызовите его из каждого.
Современный клиент по-прежнему получает 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) переносимо между поколениями по построению. Напишите современный вариант один раз.