AmigaОбучение ИИ
Модуль 3 · Транспорты и коммуникация · урок 10 из 13

Транспорт Streamable HTTP

12 мин чтения▶ есть видеоверсия

Когда сервер нужен всей команде или должен жить на отдельной машине, stdio не подходит: у хоста нет возможности запустить процесс на чужом сервере. Для этого есть Streamable HTTP. К концу урока вы будете понимать, что именно уходит в каждом запросе, когда сервер отвечает одним JSON-объектом, а когда потоком событий, и какие заголовки и проверки обязательны.

Одна точка входа

Сервер публикует единственный HTTP-адрес — в спецификации он называется MCP endpoint, например https://mcp.example.com/mcp. Клиент отправляет каждое JSON-RPC-сообщение отдельным POST на этот адрес. Тело — одно сообщение: запрос или уведомление. Ответы клиент серверу не отправляет, потому что в текущей ревизии сервер не задаёт вопросов.

Название транспорта происходит от того, что ответ может быть потоком. Сервер сам решает для каждого запроса:

  • Content-Type: application/json — один JSON-объект с результатом или ошибкой. Подходит для быстрых запросов вроде tools/list.
  • Content-Type: text/event-stream — поток Server-Sent Events (SSE), в котором сервер сначала может отправить уведомления, относящиеся к этому запросу (прогресс, логи), а в конце — ответ, после чего закрывает поток.

Клиент обязан уметь оба варианта и заявляет это заголовком Accept: application/json, text/event-stream. Если тело было уведомлением, сервер отвечает 202 Accepted без тела.

Вот как выглядит вызов инструмента с прогрессом из практикума по уведомлениям, если снять его с провода. Запрос:

http
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: build_report

{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"build_report","arguments":{"sprint":"2026-09"},"_meta":{"progressToken":"p1","io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}

Ответ:

http
HTTP/1.1 200 OK
Content-Type: text/event-stream
X-Accel-Buffering: no

data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"p1","progress":1,"total":6,"message":"Проанализирована WEB-301"}}

data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"p1","progress":2,"total":6,"message":"Проанализирована WEB-305"}}

data: {"jsonrpc":"2.0","id":3,"result":{"resultType":"complete","content":[{"type":"text","text":"Отчёт по спринту 2026-09 ..."}]}}

SSE — это простой текстовый формат: строки data: с содержимым, пустая строка отделяет события. Браузеры и HTTP-библиотеки умеют его читать построчно, не дожидаясь конца ответа. Строка, начинающаяся с двоеточия, — комментарий; серверам рекомендуют слать такие строки как keep-alive на долгих потоках, чтобы прокси не закрыли соединение.

Заголовок X-Accel-Buffering: no — подсказка обратным прокси вроде nginx не буферизовать ответ. Без него прокси может накопить все события и отдать их клиенту разом в конце — прогресс превращается в бессмысленную пачку.

Заголовки-зеркала

В ревизии 2026-07-28 часть полей тела дублируется в заголовках, чтобы балансировщики и шлюзы могли маршрутизировать запросы, не разбирая JSON:

  • MCP-Protocol-Version — обязателен на каждом POST и должен совпадать с io.modelcontextprotocol/protocolVersion в _meta;
  • Mcp-Method — имя метода, обязателен;
  • Mcp-Name — имя инструмента, промпта или URI ресурса для tools/call, prompts/get, resources/read.

Сервер обязан проверить, что заголовки совпадают с телом, и при расхождении ответить 400 Bad Request с JSON-RPC-ошибкой -32020 (HeaderMismatch). Смысл проверки — безопасность: если балансировщик принимает решение по заголовку, а сервер выполняет то, что в теле, подмена одного из них открывает дыру. Клиенты SDK ставят эти заголовки сами.

Ещё два кода из спецификации. Неподдерживаемая версия — 400 с ошибкой -32022 и списком поддерживаемых версий. Неизвестный метод — 404 с ошибкой -32601; JSON-RPC-тело здесь важно, потому что позволяет отличить современный сервер от старого HTTP+SSE-сервера, у которого по этому адресу просто ничего нет.

Отмена и долгие потоки

В Streamable HTTP у каждого запроса свой поток ответа, и это упрощает отмену: клиент просто закрывает соединение. Сервер обязан считать разрыв отменой, прекратить работу и не отправлять больше ничего по этому запросу. Уведомление notifications/cancelled на этом транспорте не используется.

Уведомления об изменениях — «список инструментов изменился», «ресурс обновился» — доставляются иначе. Клиент отправляет запрос subscriptions/listen с фильтром, и ответом на него служит SSE-поток, который не закрывается: сервер шлёт в него подтверждение подписки, а затем только те уведомления, которые клиент запросил. Прогресс и логи в этот поток не попадают — они всегда идут в поток того запроса, к которому относятся.

Безопасность

Три требования спецификации, которые часто пропускают.

Сервер обязан проверять заголовок Origin и отвечать 403 на неизвестный. Это защита от DNS rebinding: вредоносная веб-страница может заставить браузер пользователя отправить запрос на localhost, и без проверки Origin локальный MCP-сервер послушно выполнит его.

Локальный сервер должен слушать 127.0.0.1, а не 0.0.0.0. Иначе он доступен всем в той же сети.

Сервер должен аутентифицировать соединения. Для HTTP спецификация описывает OAuth 2.1: клиент получает токен у сервера авторизации и присылает его в заголовке Authorization: Bearer ... с каждым запросом; сервер обязан проверять, что токен выдан именно для него. Подробнее — в уроке «Состояние и Streamable HTTP».

Запуск в SDK

Простейший способ — параметр transport у run():

python
if __name__ == "__main__":
    mcp.run(transport="streamable-http", host="127.0.0.1", port=8000)

Endpoint будет http://127.0.0.1:8000/mcp; путь меняется параметром streamable_http_path. Параметр json_response=True заставляет сервер всегда отвечать одним JSON-объектом вместо SSE — уведомления при этом теряются, но с некоторыми прокси так проще. Те же настройки принимает CLI: uv run mcp run server.py --transport streamable-http.

Для продакшена сервер упаковывают в ASGI-приложение и запускают через uvicorn или встраивают в существующее приложение на Starlette или FastAPI:

python
app = mcp.streamable_http_app()
bash
uvicorn server:app --host 127.0.0.1 --port 8000

При развёртывании на настоящем имени хоста нужна одна обязательная настройка. По умолчанию защита от DNS rebinding принимает только localhost-адреса, и любой запрос на mcp.example.com получит 421 Misdirected Request. Лечится явным списком:

python
from mcp.server.transport_security import TransportSecuritySettings

security = TransportSecuritySettings(
    allowed_hosts=["mcp.example.com", "mcp.example.com:*"],
    allowed_origins=["https://app.example.com"],
)
app = mcp.streamable_http_app(transport_security=security)

Если TLS терминирует прокси, а uvicorn работает по HTTP за ним, добавьте --proxy-headers --forwarded-allow-ips=<адрес прокси>, иначе сервер будет считать себя http:// и клиенты откажутся от понижения схемы.

Клиент

Клиенту достаточно адреса:

python
from mcp import Client

async with Client("http://127.0.0.1:8000/mcp") as client:
    print(await client.list_tools())

Заголовки (например, токен) и таймауты задаются на HTTP-клиенте, который передаётся транспорту:

python
import httpx2

from mcp import Client
from mcp.client.streamable_http import streamable_http_client

async with httpx2.AsyncClient(
    headers={"Authorization": "Bearer ..."},
    timeout=httpx2.Timeout(30.0, read=300.0),
) as http:
    transport = streamable_http_client("https://mcp.example.com/mcp", http_client=http)
    async with Client(transport) as client:
        ...

Длинный read-таймаут здесь не случаен: SSE-поток инструмента, который работает минуту, живёт минуту, и дефолтные 5–10 секунд его оборвут.

Попробуйте сами

10–15 мин на рабочем месте
  1. Запустите сервер из практикума по уведомлениям через --transport streamable-http и вызовите tools/list через curl с заголовками из урока (Accept, MCP-Protocol-Version, Mcp-Method) и телом с _meta. Посмотрите, в каком формате пришёл ответ. Затем уберите Mcp-Method и посмотрите, что изменилось.
  2. Вызовите тем же способом build_report с progressToken в _meta и флагом -N у curl, чтобы видеть поток по мере прихода событий.
  3. Прочитайте раздел Security & Endpoint спецификации транспорта и проверьте свой сервер по трём пунктам: Origin, адрес прослушивания, аутентификация.

Коротко

  • Один endpoint, каждый запрос — отдельный POST; клиент присылает Accept: application/json, text/event-stream.
  • Ответ — либо один JSON, либо SSE-поток: уведомления по запросу, затем результат, затем закрытие. Уведомление от клиента — 202.
  • Заголовки MCP-Protocol-Version, Mcp-Method, Mcp-Name дублируют тело; расхождение — 400 и -32020.
  • Отмена — закрытие потока ответа; уведомления об изменениях — через subscriptions/listen.
  • Безопасность: проверка Origin (иначе 403), прослушивание 127.0.0.1, аутентификация по Authorization: Bearer.
  • SDK: mcp.run(transport="streamable-http") или mcp.streamable_http_app() + uvicorn; при реальном хосте — TransportSecuritySettings, иначе 421.
  • Клиент: Client("http://.../mcp"); заголовки и таймауты — на httpx2.AsyncClient.

Видеоверсия

Сценарий озвучки · 476 слов, ≈ 4 мин

Когда сервер нужен всей команде или должен жить на отдельной машине, стандартный ввод-вывод не подходит: хост не может запустить процесс на чужом сервере. Для этого есть Streamable HTTP. Разберём, что именно уходит в каждом запросе и что приходит в ответ.

Сервер публикует единственный адрес — точку входа. Клиент отправляет каждое сообщение отдельным пост-запросом на этот адрес. В теле — одно сообщение: запрос или уведомление. Название транспорта происходит от того, что ответ может быть потоком. Для каждого запроса сервер сам решает: ответить одним джейсон-объектом или открыть поток событий. Поток нужен, когда до результата есть что сказать: уведомления о прогрессе, логи. Сервер отправляет их одно за другим, в конце — результат, и закрывает поток. Клиент обязан уметь оба варианта и заявляет об этом заголовком Accept. А если клиент прислал уведомление, сервер отвечает кодом двести два без тела.

Поток событий — это простой текстовый формат: строки, начинающиеся с «data», пустая строка между событиями. Библиотеки читают его построчно, не дожидаясь конца. Есть одна практическая тонкость с прокси. Обратный прокси вроде nginx любит буферизовать ответ и отдавать целиком. Тогда прогресс превращается в бессмысленную пачку в конце. Поэтому сервер ставит специальный заголовок, который просит прокси не буферизовать.

В последней ревизии часть полей тела дублируется в заголовках: версия протокола, имя метода, имя инструмента. Это сделано для балансировщиков и шлюзов, чтобы они могли маршрутизировать запросы, не разбирая джейсон. Сервер обязан проверить, что заголовки совпадают с телом, и при расхождении ответить ошибкой. Смысл — безопасность: если балансировщик принимает решение по заголовку, а сервер выполняет то, что в теле, подмена одного из них открывает дыру. Клиенты из SDK ставят заголовки сами.

Отмена на этом транспорте проще, чем на стандартном вводе-выводе: у каждого запроса свой поток ответа, и клиент просто закрывает соединение. Сервер обязан считать разрыв отменой. А уведомления об изменениях — список инструментов поменялся, ресурс обновился — идут через отдельный запрос-подписку, ответом на который служит поток, который не закрывается.

Три требования безопасности, которые часто пропускают. Сервер обязан проверять заголовок Origin: это защита от атаки, при которой вредоносная веб-страница заставляет браузер пользователя стучаться в локальный сервер. Локальный сервер должен слушать только локальный адрес, а не все интерфейсы. И сервер должен аутентифицировать соединения — для эйч-ти-ти-пи спецификация описывает OAuth, токен приходит в заголовке авторизации с каждым запросом.

Как это запускается в SDK. Простейший вариант — вызвать run с транспортом streamable-http, адресом и портом. Для продакшена сервер упаковывают в ASGI-приложение и запускают через uvicorn или встраивают в существующее приложение. И одна обязательная настройка при развёртывании на настоящем имени хоста: по умолчанию защита принимает только локальные адреса, и любой запрос на реальный домен получит код четыреста двадцать один. Лечится явным списком разрешённых хостов.

Клиенту достаточно адреса. Заголовки вроде токена и таймауты задаются на эйч-ти-ти-пи-клиенте, который передаётся транспорту. И не забудьте про длинный таймаут чтения: поток инструмента, который работает минуту, живёт минуту, и дефолтные секунды его оборвут.

В следующем уроке разберём этот транспорт глубже: как он выглядел в предыдущей ревизии с сессиями и возобновлением потоков, и почему это всё убрали.

Отметка хранится только в вашем браузере