Транспорт Streamable HTTP
Когда сервер нужен всей команде или должен жить на отдельной машине, 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 без тела.
Вот как выглядит вызов инструмента с прогрессом из практикума по уведомлениям, если снять его с провода. Запрос:
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/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():
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:
app = mcp.streamable_http_app()uvicorn server:app --host 127.0.0.1 --port 8000При развёртывании на настоящем имени хоста нужна одна обязательная настройка. По умолчанию защита от DNS rebinding принимает только localhost-адреса, и любой запрос на mcp.example.com получит 421 Misdirected Request. Лечится явным списком:
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:// и клиенты откажутся от понижения схемы.
Клиент
Клиенту достаточно адреса:
from mcp import Client
async with Client("http://127.0.0.1:8000/mcp") as client:
print(await client.list_tools())Заголовки (например, токен) и таймауты задаются на HTTP-клиенте, который передаётся транспорту:
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 мин на рабочем месте- Запустите сервер из практикума по уведомлениям через
--transport streamable-httpи вызовитеtools/listчерезcurlс заголовками из урока (Accept,MCP-Protocol-Version,Mcp-Method) и телом с_meta. Посмотрите, в каком формате пришёл ответ. Затем уберитеMcp-Methodи посмотрите, что изменилось. - Вызовите тем же способом
build_reportсprogressTokenв_metaи флагом-Nу curl, чтобы видеть поток по мере прихода событий. - Прочитайте раздел 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 или встраивают в существующее приложение. И одна обязательная настройка при развёртывании на настоящем имени хоста: по умолчанию защита принимает только локальные адреса, и любой запрос на реальный домен получит код четыреста двадцать один. Лечится явным списком разрешённых хостов.
Клиенту достаточно адреса. Заголовки вроде токена и таймауты задаются на эйч-ти-ти-пи-клиенте, который передаётся транспорту. И не забудьте про длинный таймаут чтения: поток инструмента, который работает минуту, живёт минуту, и дефолтные секунды его оборвут.
В следующем уроке разберём этот транспорт глубже: как он выглядел в предыдущей ревизии с сессиями и возобновлением потоков, и почему это всё убрали.
