Streamable HTTP в деталях
Прошлый урок описывал транспорт таким, каким он стал в ревизии 2026-07-28. Но хосты и серверы обновляются не одновременно: часть клиентов ещё год будет присылать initialize и ждать Mcp-Session-Id. Чтобы читать чужие логи и понимать, почему запрос вернул 404, нужно знать предыдущую форму транспорта — и логику, по которой её упростили. К концу урока вы будете различать три поколения HTTP-транспорта и понимать, как один сервер обслуживает их все.
Поколение первое: HTTP+SSE
В первой ревизии протокола (2024-11-05) HTTP-транспорт состоял из двух адресов. Клиент открывал GET на адрес вроде /sse и получал бесконечный SSE-поток, первым событием в котором сервер присылал endpoint — адрес для отправки сообщений. Все сообщения клиента уходили POST-ами на этот второй адрес, а все ответы и запросы сервера приходили в первый поток.
Схема работала, но требовала держать одно долгоживущее соединение на всё время работы, а любой разрыв означал потерю всего. Она объявлена устаревшей с 2025-03-26 и заменена Streamable HTTP. Узнать такой сервер просто: POST на его адрес возвращает 404 или 405, а GET открывает поток с событием endpoint. Клиенты, которым нужна совместимость, до сих пор используют эту проверку как последний запасной вариант.
Поколение второе: Streamable HTTP с сессиями
Ревизии с 2025-03-26 по 2025-11-25 ввели единый endpoint, который вы знаете, но с тремя механизмами, которых больше нет.
Сессии. Обмен начинался с POST initialize. В ответе сервер мог прислать заголовок Mcp-Session-Id — криптографически случайную строку из видимых ASCII-символов. Дальше клиент обязан был присылать этот заголовок на каждом запросе; запрос без него получал 400. Сервер мог завершить сессию в любой момент, и тогда запросы с её идентификатором получали 404 — клиент обязан был начать заново с initialize. Клиент, которому сессия больше не нужна, отправлял DELETE с тем же заголовком; сервер вправе был ответить 405, если не поддерживает завершение по инициативе клиента.
POST /mcp HTTP/1.1
Accept: application/json, text/event-stream
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"sampling":{}},"clientInfo":{"name":"amiga-assistant","version":"0.4.0"}}}
HTTP/1.1 200 OK
Content-Type: application/json
Mcp-Session-Id: 1868a90c-7f3e-4c1b-9a2d-0f5e6b7c8d9e
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"reports","version":"1.2.0"}}}После этого — POST с notifications/initialized и заголовком сессии, ответ 202, и только потом рабочие запросы. Все они несут Mcp-Session-Id и MCP-Protocol-Version: 2025-11-25.
GET-поток. Клиент мог открыть GET на тот же endpoint с Accept: text/event-stream и получить отдельный SSE-поток, не привязанный ни к какому запросу. В него сервер отправлял то, что хотел сказать по своей инициативе: уведомления об изменении списков и — главное — собственные запросы, например sampling/createMessage. Клиент отвечал на них POST-ом с JSON-RPC-ответом (и получал 202). Сервер, которому такой поток не нужен, отвечал на GET кодом 405.
Возобновление. Любое соединение может оборваться, а в SSE-потоке в этот момент могли быть недоставленные сообщения. Поэтому сервер мог нумеровать события SSE полем id, а клиент после разрыва открывал GET с заголовком Last-Event-ID — и сервер повторял всё, что было после этого события, но только из того потока, в котором оно было. Серверу разрешалось закрывать соединение сам по себе, чтобы не держать его долго, прислав перед этим поле retry — через сколько миллисекунд переподключаться. Из-за этого разрыв соединения нельзя было считать отменой запроса: для отмены клиент отправлял notifications/cancelled.
Вот важное следствие для развёртывания. Сессия, GET-поток и буфер событий для повтора живут в памяти конкретного процесса. Если за балансировщиком три экземпляра сервера, запрос с Mcp-Session-Id обязан попасть в тот же экземпляр, что и initialize, — нужна «липкая» маршрутизация. Иначе клиент получает 404 Session not found на ровном месте. Это самая частая проблема при выкладке серверов второго поколения.
Поколение третье: что убрали и почему
Ревизия 2026-07-28 убрала все три механизма.
Сессий нет: версия и возможности клиента приходят в _meta каждого запроса, серверу нечего запоминать. Сервер, который получил Mcp-Session-Id от старого клиента, обязан его игнорировать и не выдавать свой.
GET-потока нет: GET на endpoint получает 405. Всё, что раньше сервер отправлял по своей инициативе, разделили на две части. Собственные запросы сервера (sampling, roots, элиситация) стали частью результата input_required — паттерн MRTR, о котором мы говорили в первом модуле. Уведомления об изменениях получают через subscriptions/listen: это обычный POST-запрос, ответом на который служит долгий SSE-поток. Разница с GET-потоком кажется косметической, но она принципиальна: поток теперь привязан к запросу с id, клиент явно говорит, что хочет получать, а сервер подтверждает подписку и закрывает её ответом на исходный запрос.
Возобновления нет: Last-Event-ID игнорируется. Если поток оборвался, клиент повторяет запрос. Для обычного tools/call это означает повторное выполнение — поэтому инструменты стоит проектировать идемпотентными. Для подписки — переоткрыть subscriptions/listen. Отмена — закрытие потока, notifications/cancelled на HTTP не используется.
Итог: в третьем поколении каждый POST самодостаточен. Любой экземпляр за балансировщиком может обработать любой запрос. Единственное, что нужно держать в одном месте, — открытые потоки подписок, и для них спецификация оставила серверу свободу.
Как клиент понимает, с кем говорит
Клиент, который поддерживает и новую, и старую ревизии, действует так. Отправляет обычный современный запрос. Если пришёл 200 — сервер современный. Если 400, смотрит в тело: современный сервер положит туда узнаваемую JSON-RPC-ошибку (-32022 неподдерживаемая версия, -32020 заголовки, -32021 возможности) — тогда клиент исправляет запрос и остаётся на современном протоколе. Если тело пустое или незнакомое — сервер старый, клиент переходит к initialize и дальше работает по правилам второго поколения. Если и это не удалось (404/405) — пробует GET в надежде на событие endpoint первого поколения.
Клиенту Client из SDK v2 это всё уже известно: режим mode="auto" по умолчанию делает именно это. mode="legacy" принудительно ведёт себя как клиент второго поколения — полезно, чтобы проверить, как ваш сервер работает со старыми хостами.
Как сервер обслуживает всех
Сервер на SDK v2 обслуживает оба поколения Streamable HTTP из одного приложения без настройки. По заголовку MCP-Protocol-Version запрос уходит либо в современный обработчик, либо в старый, который ждёт initialize, выдаёт Mcp-Session-Id, держит GET-поток и словарь сессий в памяти процесса. Два параметра управляют старой веткой: session_idle_timeout (по умолчанию 1800 секунд простоя, после чего сессия закрывается и клиент получает 404) и max_sessions (по умолчанию 10 000, дальше — 503).
Для старых клиентов за балансировщиком есть два пути. Липкая маршрутизация по Mcp-Session-Id — и тогда всё работает как задумано. Или stateless_http=True — сервер перестаёт держать сессии и создаёт одноразовую на каждый запрос. Плата за это: у старой ветки исчезает обратный канал, так что ctx.elicit() и sampling через сессию упадут с ошибкой NoBackChannelError, а уведомления по GET-потоку просто не доставятся. На современных клиентов stateless_http не влияет — они и так без сессий.
Ваш код инструментов при этом один. Единственная развилка — уведомления об изменениях: современные клиенты слушают subscriptions/listen, и для них нужен ctx.notify_tools_changed(); старые слушают GET-поток, и для них — ctx.session.send_tool_list_changed(). Чтобы достучаться до всех, вызывают оба.
Попробуйте сами
10–15 мин на рабочем месте- Запустите любой свой сервер через
streamable-httpи подключитесь к нему клиентом SDK дважды: сmode="auto"и сmode="legacy". Выведитеclient.protocol_versionв обоих случаях. Затем в stderr сервера найдите, чем отличается обработка. - Повторите обмен второго поколения через
curl:initializeбез заголовкаMCP-Protocol-Version, найдитеMcp-Session-Idв заголовках ответа (curl -i), отправьтеtools/listс этим заголовком, а потом — без него. Сравните коды ответов. - Отправьте на современный сервер
GET /mcpиDELETE /mcp. Убедитесь, что оба получают405, и объясните себе, почему это правильно.
Коротко
- Три поколения: HTTP+SSE (два адреса,
2024-11-05), Streamable HTTP с сессиями (2025-03-26…2025-11-25), Streamable HTTP без состояния (2026-07-28). - Второе поколение:
Mcp-Session-Idпослеinitializeна каждом запросе (400без него,404при истечении,DELETEдля завершения), GET-поток для запросов сервера, возобновление черезidсобытий иLast-Event-ID. - Сессии и буферы живут в памяти процесса — за балансировщиком нужна липкая маршрутизация.
- Третье поколение убрало сессии, GET и возобновление: запросы сервера — через MRTR, уведомления об изменениях — через
subscriptions/listen, отмена — закрытием потока. - Клиент определяет поколение сервера по ответу на современный запрос и телу
400; SDK делает это вmode="auto". - Сервер на SDK v2 обслуживает оба поколения из одного приложения;
stateless_http=Trueснимает липкость для старых клиентов ценой обратного канала.
Видеоверсия
Сценарий озвучки · 505 слов, ≈ 4 мин
Прошлый урок описывал транспорт таким, каким он стал в последней ревизии. Но хосты и серверы обновляются не одновременно, и ещё год вы будете встречать клиентов, которые присылают рукопожатие и ждут идентификатор сессии. Чтобы читать чужие логи и понимать, почему запрос вернул четыреста четыре, нужно знать предыдущую форму транспорта.
Поколений три. Первое — самый первый эйч-ти-ти-пи-транспорт с двумя адресами. Клиент открывал долгий поток событий на одном адресе, сервер первым же событием присылал второй адрес, и все сообщения клиента уходили туда. Один разрыв соединения — и всё потеряно. Схема устарела ещё весной две тысячи двадцать пятого.
Второе поколение — Streamable HTTP с сессиями, ревизии с марта по ноябрь две тысячи двадцать пятого. Единый адрес, который вы знаете, но с тремя механизмами, которых больше нет. Первый — сессии. Обмен начинался с рукопожатия, и в ответе сервер мог прислать заголовок с идентификатором сессии. Дальше клиент обязан был присылать его на каждом запросе. Без него — четыреста, с истёкшим — четыреста четыре, и клиент начинает заново. Второй механизм — отдельный поток по GET-запросу, не привязанный ни к какому запросу клиента. В него сервер отправлял то, что хотел сказать по своей инициативе, включая собственные запросы вроде sampling. Третий — возобновление. Сервер нумеровал события потока, а клиент после разрыва присылал номер последнего полученного, и сервер повторял всё, что было после. Из-за этого разрыв соединения нельзя было считать отменой.
Важное следствие для развёртывания: сессия, поток и буфер для повтора живут в памяти конкретного процесса. Если за балансировщиком три экземпляра, запрос с идентификатором сессии обязан попасть в тот же экземпляр, что и рукопожатие. Нужна липкая маршрутизация, иначе клиент получает «сессия не найдена» на ровном месте. Это самая частая проблема при выкладке таких серверов.
Третье поколение убрало все три механизма. Сессий нет: версия и возможности клиента приходят в каждом запросе, серверу нечего запоминать. GET-потока нет. Собственные запросы сервера стали частью результата «нужен ввод», а уведомления об изменениях получают через отдельный запрос-подписку, ответом на который служит долгий поток. Разница с GET-потоком принципиальна: поток теперь привязан к запросу с идентификатором, клиент явно говорит, что хочет получать, а сервер подтверждает. Возобновления нет: если поток оборвался, клиент повторяет запрос, поэтому инструменты стоит делать идемпотентными. Отмена — закрытие потока. Итог: каждый пост-запрос самодостаточен, и любой экземпляр за балансировщиком может обработать любой запрос.
Как клиент понимает, с кем говорит. Отправляет современный запрос. Двести — сервер современный. Четыреста — смотрит в тело: если там узнаваемая ошибка протокола, сервер современный, надо исправить запрос. Если тело пустое или незнакомое — сервер старый, переходим к рукопожатию. Если и это не удалось — пробуем GET в надежде на первое поколение. Клиент из SDK делает это сам в режиме auto.
А сервер на SDK второй версии обслуживает оба поколения из одного приложения без настройки: по заголовку версии запрос уходит в современный обработчик или в старый с сессиями. Для старых клиентов за балансировщиком два пути: липкая маршрутизация или режим без состояния, при котором сервер перестаёт держать сессии — но тогда у старой ветки исчезает обратный канал, и элиситация через сессию упадёт. Код инструментов при этом один и тот же.
В следующем, последнем содержательном уроке поговорим о состоянии в целом: что сервер вправе помнить, где это хранить и как это связано с аутентификацией.
