Состояние и Streamable HTTP
Слово «сессия» встречается в разговорах об MCP чаще, чем в спецификации. Из-за этого возникают два противоположных заблуждения: что сервер может спокойно хранить данные пользователя в памяти между вызовами — и что серверу вообще ничего нельзя помнить. Этот урок расставляет границы. К концу вы будете знать, какое состояние допустимо, где оно должно лежать, чтобы сервер можно было запустить в трёх экземплярах, и как в эту картину встроена аутентификация.
Что говорит спецификация
Ревизия 2026-07-28 называет MCP протоколом без состояния и формулирует это жёстко. Вся информация, необходимая для обработки запроса, содержится в самом запросе. Сервер не должен опираться на предыдущие запросы того же соединения, чтобы узнать версию, возможности или личность клиента, — всё это в _meta. Сервер не должен требовать, чтобы связанные операции шли по одному соединению или к одному процессу. И самое важное: состояние, которое должно пережить несколько запросов, обязано быть привязано к явному идентификатору, который клиент передаёт в каждом запросе.
Отдельно подчёркнуто: открытое соединение — не разговор. Клиент может перемежать на одном stdio-процессе или одном HTTP-соединении запросы из разных бесед, и сервер не имеет права считать соединение признаком того, что это тот же пользователь и та же задача.
Это не философия, а инженерное требование. Оно ровно то, что позволяет поставить сервер за балансировщик без липкой маршрутизации, перезапустить экземпляр посреди работы и обслуживать тысячи клиентов без словаря сессий в памяти.
Что при этом нужно помнить
Требование звучит абсолютно, но у настоящего сервера есть как минимум четыре вида состояния, и для каждого есть правильное место.
Состояние процесса. Пул соединений к базе, HTTP-клиент, кэш справочников. Оно не относится к конкретному клиенту, живёт столько же, сколько процесс, и ничего не нарушает. В SDK для него есть lifespan: функция, которая выполняется при старте, отдаёт объект, и этот объект доступен в инструментах через ctx.request_context.lifespan_context.
Состояние между раундами одного запроса. Инструмент задал вопрос пользователю или попросил модель — и теперь ждёт повтора вызова с ответом. Между первым и вторым вызовом что-то нужно помнить: как минимум, что именно спрашивали. Для этого в MRTR есть requestState: сервер упаковывает всё нужное в непрозрачную строку и отдаёт клиенту, а клиент возвращает её без изменений. Память здесь — у клиента, и сервер остаётся без состояния.
Строка проходит через клиента, а значит, через потенциального противника. Спецификация требует считать requestState недоверенным вводом и защищать его целостность (HMAC или шифрование с аутентификацией), если от него зависит авторизация или логика, а также включать в него срок действия, идентификатор принципала и отпечаток исходного запроса, чтобы строку нельзя было переиграть от другого пользователя или подставить в другой вызов. SDK v2 делает это сам: MCPServer шифрует requestState (AES-GCM), привязывает его к TTL в 600 секунд на раунд, к принципалу из токена, к методу, имени инструмента и отпечатку аргументов. Подделка или просрочка дают ошибку -32602 Invalid or expired requestState.
Есть одна ловушка при масштабировании. Ключ шифрования по умолчанию генерируется при старте процесса, и у каждого экземпляра он свой. Первый вызов попал на экземпляр A, повтор — на экземпляр B, и B не может расшифровать строку. Решение — общий ключ и одинаковое имя сервера у всех экземпляров:
import os
from mcp.server import MCPServer
from mcp.server.request_state import RequestStateSecurity
mcp = MCPServer(
"reports",
request_state_security=RequestStateSecurity(keys=[os.environ["MCP_STATE_KEY"].encode()]),
)Имя сервера входит в привязку: экземпляры с разными именами отвергнут строки друг друга даже при общем ключе.
Состояние задачи. Отчёт строится десять минут, и держать открытым HTTP-поток столько нельзя. Правильный паттерн — явный идентификатор, как и требует спецификация:
@mcp.tool()
async def start_report(sprint: str, ctx: Context) -> str:
"""Запустить сборку отчёта в фоне и вернуть идентификатор задачи."""
job_id = await jobs.create(sprint) # ваша очередь: Redis, база, брокер
return f"Задача {job_id} запущена. Проверяйте статус через report_status."
@mcp.tool()
async def report_status(job_id: str) -> str:
"""Статус и результат задачи по идентификатору."""
job = await jobs.get(job_id)
if job is None:
return f"Задача {job_id} не найдена."
return job.summary()Идентификатор возвращается модели, модель передаёт его в следующий вызов, а сами данные лежат во внешнем хранилище, куда достучится любой экземпляр. Для этого сценария у спецификации есть и стандартное расширение Tasks (io.modelcontextprotocol/tasks) с опросом статуса и долговечными ссылками на результат; оно необязательное и требует поддержки с обеих сторон, так что явный job_id остаётся универсальным решением.
Состояние подписок. Клиент открыл subscriptions/listen, и его поток привязан к тому экземпляру, который его принял. Когда инструмент на экземпляре B меняет список инструментов и вызывает ctx.notify_tools_changed(), подписчик на экземпляре A об этом не узнает — если экземпляры не связаны. В SDK для этого есть протокол SubscriptionBus из двух методов: встроенная реализация работает в памяти одного процесса, а для нескольких его реализуют поверх Redis, NATS или другой шины и передают один и тот же объект каждому MCPServer.
Сессии старых клиентов
Всё выше — про современный протокол. Клиенты ревизии 2025-11-25 приносят с собой сессии с Mcp-Session-Id, и для них сервер на SDK держит в памяти словарь: согласованную версию, открытые потоки, фоновые задачи. Это состояние процесса, привязанное к клиенту, — ровно то, чего современная ревизия избегает. Выбор при развёртывании описан в прошлом уроке: либо липкая маршрутизация по заголовку, либо stateless_http=True с потерей обратного канала для старой ветки. Современные клиенты этот выбор не затрагивает.
Масштабирование: чек-лист
Сервер готов работать в нескольких экземплярах, если:
- инструменты не хранят ничего про пользователя в переменных модуля и глобальных словарях;
- всё, что переживает запрос, лежит во внешнем хранилище и адресуется идентификатором из аргументов инструмента или из токена;
RequestStateSecurityс общим ключом и одинаковым именем сервера у всех экземпляров;SubscriptionBusобщий, если сервер шлёт уведомления об изменениях;- для старых клиентов выбран режим: липкость или
stateless_http; - перед сервером — прокси с отключённой буферизацией SSE и таймаутами не меньше самого долгого инструмента.
Число рабочих процессов SDK не задаёт: это uvicorn --workers N или настройки вашей платформы. Проверку живости тоже добавляют сами:
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
@mcp.custom_route("/health", methods=["GET"])
async def health(request: Request) -> Response:
return JSONResponse({"status": "ok"})Аутентификация
Кто пользователь — это тоже состояние, и спецификация определяет, откуда его брать. Для HTTP-транспорта описан OAuth 2.1, в котором MCP-сервер — это ресурсный сервер, MCP-клиент — OAuth-клиент, а сервер авторизации может быть отдельным сервисом. Цепочка такая.
Клиент делает запрос без токена и получает 401 с заголовком WWW-Authenticate, в котором указан адрес метаданных защищённого ресурса (RFC 9728). Из метаданных клиент узнаёт адрес сервера авторизации и запрашивает его метаданные (RFC 8414 или OpenID Connect Discovery). Клиент регистрируется — предпочтительно через Client ID Metadata Documents, где client_id — это HTTPS-адрес документа с его описанием; динамическая регистрация по RFC 7591 в 2026-07-28 помечена устаревшей. Дальше стандартный код-флоу с PKCE и обязательным параметром resource (RFC 8707), в котором клиент называет адрес MCP-сервера, для которого просит токен. Полученный токен клиент присылает в заголовке Authorization: Bearer с каждым запросом; в строке запроса токены запрещены.
На стороне сервера два обязательных правила. Проверять, что токен выдан именно ему (аудитория), и отвечать 401 на невалидный или просроченный; 403 — когда прав не хватает, с указанием требуемых scope в WWW-Authenticate. И никогда не передавать токен клиента дальше — во внешние API сервер ходит со своими учётными данными или с токеном, полученным отдельным обменом.
Транспорт stdio во всём этом не участвует: там нет заголовков, и учётные данные приходят из окружения процесса.
Для состояния из этого следует практическое правило: ключ для пользовательских данных — субъект из проверенного токена, а не что-то, что клиент присылает в аргументах. SDK v2 умеет проверять токены и связывает принципала с requestState автоматически; настройки описаны в разделе Authorization документации SDK.
Попробуйте сами
10–15 мин на рабочем месте- Найдите в своём сервере всё, что лежит в переменных модуля, и разложите по четырём видам состояния из урока. Для каждого решите, где оно должно жить при трёх экземплярах.
- Запустите сервер с инструментом, использующим
Resolve, в двух процессах на разных портах (без общего ключа) и черезnginxили любой простой балансировщик направьте запросы по кругу. Добейтесь ошибкиInvalid or expired requestState, затем почините общим ключом. - Прочитайте раздел Authorization спецификации и выпишите, что обязан сделать сервер (MUST), а что желательно (SHOULD). Сравните с тем, что делает ваш прокси или шлюз.
Коротко
- MCP
2026-07-28— протокол без состояния: всё нужное для запроса — в запросе; соединение — не разговор. - Состояние процесса (пулы, кэши) — в lifespan; состояние между раундами — в
requestState, который SDK шифрует и привязывает к TTL, принципалу и запросу. - В нескольких экземплярах нужен общий ключ
RequestStateSecurityи одинаковое имя сервера. - Долгие задачи — явный идентификатор в аргументах и внешнее хранилище; есть необязательное расширение Tasks.
- Подписки привязаны к экземпляру; для нескольких — общий
SubscriptionBus. - Старые клиенты приносят сессии: липкая маршрутизация или
stateless_http. - Аутентификация по HTTP — OAuth 2.1:
401с метаданными ресурса, PKCE, параметрresource, токен вAuthorizationна каждом запросе, проверка аудитории, никакого проброса токенов; stdio — учётные данные из окружения.
Видеоверсия
Сценарий озвучки · 512 слов, ≈ 4 мин
Слово «сессия» звучит в разговорах об MCP чаще, чем в спецификации. Из-за этого два противоположных заблуждения: что сервер может хранить данные пользователя в памяти между вызовами — и что серверу вообще ничего нельзя помнить. Расставим границы.
Спецификация в последней ревизии называет MCP протоколом без состояния, и формулирует жёстко. Всё, что нужно для обработки запроса, содержится в самом запросе. Сервер не должен опираться на предыдущие запросы того же соединения, чтобы узнать версию или личность клиента. И главное: состояние, которое должно пережить несколько запросов, обязано быть привязано к явному идентификатору, который клиент передаёт каждый раз. Открытое соединение — не разговор. Это не философия, а инженерное требование: ровно оно позволяет поставить сервер за балансировщик без липкой маршрутизации и перезапускать экземпляры посреди работы.
Но у настоящего сервера есть как минимум четыре вида состояния, и для каждого есть правильное место. Первый — состояние процесса: пул соединений к базе, кэш справочников. Оно не относится к клиенту и живёт столько же, сколько процесс. В SDK для него есть функция жизненного цикла, которая выполняется при старте.
Второй — состояние между раундами одного запроса. Инструмент задал вопрос пользователю и ждёт повтора вызова с ответом. Между вызовами нужно помнить, что именно спрашивали. Для этого есть строка состояния запроса: сервер упаковывает всё нужное, отдаёт клиенту, клиент возвращает без изменений. Память у клиента, сервер без состояния. Строка проходит через клиента, поэтому спецификация требует считать её недоверенной и защищать: шифрование, срок действия, привязка к пользователю и к исходному запросу. SDK делает это сам. Но есть ловушка: ключ шифрования по умолчанию у каждого процесса свой. Первый вызов попал на один экземпляр, повтор — на другой, и тот не может расшифровать. Решение — общий ключ из переменной окружения и одинаковое имя сервера у всех экземпляров.
Третий — состояние задачи. Отчёт строится десять минут, держать открытым поток столько нельзя. Правильный паттерн — явный идентификатор. Инструмент «запустить отчёт» ставит задачу в очередь и возвращает её номер. Инструмент «статус отчёта» принимает номер и отдаёт результат из внешнего хранилища, куда достучится любой экземпляр.
Четвёртый — подписки. Поток подписки привязан к тому экземпляру, который его принял. Если инструмент на другом экземпляре меняет список инструментов, подписчик об этом не узнает — если экземпляры не связаны шиной. В SDK для этого есть протокол шины подписок из двух методов; для нескольких процессов его реализуют поверх Redis или другой шины.
Старые клиенты приносят с собой сессии, и это состояние процесса, привязанное к клиенту. Выбор при развёртывании: липкая маршрутизация или режим без состояния с потерей обратного канала для старой ветки.
И аутентификация — тоже состояние, и спецификация определяет, откуда его брать. Для эйч-ти-ти-пи это OAuth два-один. Клиент делает запрос без токена, получает четыреста один с адресом метаданных. Из метаданных узнаёт сервер авторизации, регистрируется, проходит код-флоу с защитой PKCE и обязательно называет адрес MCP-сервера, для которого просит токен. Токен присылает в заголовке авторизации с каждым запросом. Сервер обязан проверить, что токен выдан именно ему, и никогда не передавать его дальше. Для стандартного ввода-вывода всё это не нужно: там учётные данные приходят из окружения. Практическое правило: ключом для пользовательских данных служит субъект из проверенного токена, а не то, что клиент прислал в аргументах.
На этом содержательная часть курса закончена. В итоговом уроке соберём всё вместе и наметим, что делать дальше.
