Перейти к содержанию

Обслуживание клиентов старого поколения

Машинный перевод

Эта страница переведена с английской документации автоматически, и основной версией остаётся английская страница. Если что-то читается неправильно, на странице Переводы объясняется, как об этом сообщить.

У 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 — ему и посвящена бо́льшая часть страницы.

Один обработчик, оба поколения

Вот инструмент, которому нужно кое-что спросить у пользователя:

server.py
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
client.py
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

Если привязка — цена, которую вы платить не готовы, изменить можно ровно одно.

server.py
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 ни один из вызовов не доходит до клиентов другого поколения. Чтобы известить всех, вызывайте оба:

server.py
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) переносимо между поколениями по построению. Напишите современный вариант один раз.