Stateless MCP-серверы: что изменилось в протоколе
В версии MCP от 2026-07-28 удалены сессии и рукопожатие initialize. Узнайте, как это влияет на настройку обратных прокси, проверки работоспособности, таймауты и аутентификацию.
Что такое stateless MCP-сервер
Stateless MCP-сервер не хранит состояние клиента между запросами. Каждый запрос содержит версию протокола, возможности клиента и учетные данные, необходимые серверу для ответа, поэтому любой процесс на любой машине может обработать любой запрос. В протоколе MCP (Model Context Protocol, формат передачи данных, который агенты используют для доступа к инструментам) это стало правилом начиная с версии 2026-07-28, в которой были удалены рукопожатие initialize и лежащая в его основе HTTP-сессия. Все изложенное здесь касается серверной части этого протокола, поэтому, если вы еще не знакомы с клиентской стороной, пошаговое руководство по изучению AI-агентов описывает цикл принятия решения о вызове инструмента до того, как возникнет необходимость в этих HTTP-деталях.
В этом заключается основной смысл эксплуатации. Сервер, который не хранит данные о клиенте, может работать за обычным балансировщиком нагрузки без привязки сессий (session affinity), перезапускаться во время развертывания без разрыва соединений с клиентами и запускаться в виде четырех идентичных процессов вместо одного. Сервер с поддержкой сессий не способен на это без дополнительных механизмов.
Model Context Protocol — это stateless-протокол: вся информация, необходимая для обработки запроса, содержится в самом запросе. Сервер обрабатывает каждый запрос независимо; состояние не должно выводиться из предыдущих запросов, даже если они переданы через то же соединение или поток.
Stateless не означает, что сервер ничего не хранит. База данных, очередь и кэш по-прежнему существуют. Это означает, что протокол не хранит состояние соединения. Поэтому сервер не должен считать соединение, процесс или открытый сокет заменой понятия «этот клиент в середине диалога». Проще всего увидеть это разделение на приложении, которое уже управляет собственными данными: read-only MCP-сервер openGym отвечает на вопросы об истории тренировок, хранящейся в собственной базе данных приложения, и эта информация никак не зависит от соединения, по которому поступил конкретный запрос.
Что было удалено в версии 2026-07-28
2026-07-28 — это текущая версия спецификации по состоянию на август 2026 года. По сравнению с 2025-11-25, из неё удалены пять элементов, которые ранее обеспечивали поддержку сессий.
- Запрос
initializeи уведомлениеnotifications/initialized. Рукопожатие (handshake) полностью отсутствует (SEP-2575). - Заголовок
Mcp-Session-Idи завершение сессии с помощью HTTPDELETE(SEP-2567). - Отдельный поток HTTP
GET, через который серверы отправляли уведомления. Он заменен наsubscriptions/listen— обычный POST-запрос, ответом на который является долгоживущий поток. - Возможность возобновления потока SSE (server-sent events). Заголовок
Last-Event-IDи идентификаторы для отдельных событий удалены, поэтому при разрыве потока текущий запрос теряется, и клиент должен повторно отправить его как новый запрос с новым идентификатором. ping,logging/setLevelиnotifications/roots/list_changed. Уровень логирования теперь является полем запроса,io.modelcontextprotocol/logLevelв_meta.
Был добавлен один метод, который обязан реализовать каждый сервер. server/discover возвращает поддерживаемые сервером версии протокола, возможности и идентификационные данные в одном вызове. Это наиболее близкий к рукопожатию механизм, который остался в протоколе, при этом вызов метода является необязательным для клиентов.
Почему транспорт сессий было сложно использовать в production
В 2025-11-25 и более ранних версиях сервер мог создавать ID сессии при инициализации и возвращать его в заголовке Mcp-Session-Id в ответе InitializeResult. Затем клиент был обязан отправлять этот заголовок с каждым последующим запросом. Согласованная версия протокола и возможности клиента хранились в оперативной памяти сервера, привязанные к этому ID. У каждого из этих решений есть эксплуатационные издержки.
- Перезапуск сервера приводил к удалению таблицы сессий. Спецификация требовала, чтобы сервер отвечал на любой запрос с недействительным ID сессии кодом
404 Not Found, а клиент был обязан начинать процесс заново с новымInitializeRequest. Любое развертывание превращалось в событие переподключения для всех активных клиентов. - Второй экземпляр (реплика) не имел доступа к сессиям первого. Масштабирование требовало использования «липких» сессий (sticky routing) на балансировщике нагрузки или общего хранилища сессий, к которому каждая реплика обращалась при каждом запросе.
- Таблица сессий занимала память, объем которой рос вместе с количеством неактивных клиентов. Отправка
DELETEбыла опциональной, и клиенты, которые закрывались без отправки этого сигнала, оставляли «мусорные» записи в памяти. - Результаты списков могли различаться в зависимости от соединения, поэтому кэширование перед сервером было небезопасным.
Отказ от сессий устраняет все четыре проблемы одновременно. Это изменение необходимо осознать перед тем, как приступать к изменению конфигурации.
Что теперь содержит каждый запрос
Каждый POST-запрос к конечной точке MCP является независимым. Версия протокола и возможности клиента передаются в теле запроса в поле _meta, а выбранные поля дублируются в HTTP-заголовках, чтобы промежуточный узел мог выполнять маршрутизацию без разбора JSON.
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: get_weather
Authorization: Bearer <access token>
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": {"location": "Seattle, WA"},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "ExampleClient", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}Поля io.modelcontextprotocol/protocolVersion и io.modelcontextprotocol/clientCapabilities обязательны для каждого запроса. Поле clientInfo не является обязательным, хотя клиентам рекомендуется его отправлять. Запрос, в котором отсутствует обязательное поле, считается некорректным, поэтому сервер должен отклонить его с ошибкой JSON-RPC -32602 и кодом HTTP 400 Bad Request.
Заголовок Mcp-Method обязателен для каждого запроса. Заголовок Mcp-Name обязателен для tools/call, resources/read и prompts/get. Значение заголовка должно совпадать со значением в теле запроса. Сервер, обрабатывающий тело запроса, должен отклонить несовпадающие данные с кодом 400 Bad Request и кодом ошибки -32020, HeaderMismatch. Это правило введено, так как балансировщик нагрузки, выполняющий маршрутизацию по заголовку, и сервер, исполняющий запрос на основе тела, являются двумя разными источниками данных. Если вы выполняете маршрутизацию или ограничение частоты запросов (rate-limiting) по этим заголовкам, сначала проверьте MCP-Protocol-Version: в ранних версиях проверка соответствия заголовка телу не проводилась, поэтому в тех версиях значениям заголовков нельзя доверять.
Несоответствие версий теперь является обычной ошибкой на уровне запроса, а не сбоем рукопожатия. Сервер, который не поддерживает запрошенную версию, отвечает кодом 400 Bad Request с ошибкой -32022, UnsupportedProtocolVersion и перечисляет поддерживаемые версии в поле data.supported. Клиент выбирает одну из них и повторяет запрос.
Где теперь находится состояние: токены, курсоры, подписки
Состояние никуда не исчезло. Оно переместилось в места, которые можно увидеть и зафиксировать в логах.
Учетные данные передаются в каждом запросе. Поскольку нет сессии, к которой можно было бы привязать идентификатор, токен доступа сопровождает каждый HTTP-вызов и проверяется при каждом обращении. Подробности приведены в разделе аутентификации ниже.
Курсоры должны содержать информацию о своей позиции. Пагинация в tools/list, resources/list, prompts/list и resources/templates/list использует непрозрачную строку курсора, которую клиенты не должны парсить или изменять. На однопроцессном сервере было принято хранить смещение в памяти, привязанное к сессии. Без сессии курсор должен содержать достаточно данных, чтобы любая реплика могла возобновить перечисление. Поэтому кодируйте позицию внутри курсора и подписывайте её, либо храните её в общем для всех реплик хранилище. Некорректный курсор должен возвращать -32602. Подписывайте курсор, так как непрозрачный курсор — это всё ещё вводимые клиентом данные, которые ваш код декодирует и которым доверяет.
Подписки принадлежат запросу, а не соединению. Клиент, которому нужны уведомления об изменениях, отправляет subscriptions/listen с фильтром, указывающим нужные типы: toolsListChanged, promptsListChanged, resourcesListChanged и resourceSubscriptions. Сервер отвечает notifications/subscriptions/acknowledged и оставляет этот поток ответов открытым. Если поток прерывается, сервер не сохраняет никаких данных, и клиент повторно отправляет subscriptions/listen для восстановления подписки.
Состояние приложения между вызовами становится явным дескриптором. Когда серверу действительно необходимо запомнить что-то между вызовами, спецификация предлагает использовать идентификатор, созданный сервером и передаваемый обратно в качестве обычного аргумента инструмента. Он отображается в схеме инструмента, его можно логировать, и он никогда не подразумевается контекстом соединения. Сервер, работающий с реальными пользовательскими данными, например self-hosted MCP email server, использует этот шаблон вместо сессии: идентификатор почтового ящика или черновика является аргументом инструмента, поэтому любая реплика может обработать следующий вызов. Многим инструментам дескриптор вообще не нужен: инструмент поиска на базе вашего экземпляра SearXNG принимает запрос и возвращает результаты, не требуя возобновления состояния для следующего вызова и не заботясь о том, какая именно реплика ответила.
Развертывание: обратный прокси, тайм-ауты, проверки работоспособности
Конечная точка MCP — это один путь, принимающий POST-запросы. Большая часть трафика представляет собой короткий запрос и JSON-ответ, с чем справляется любой прокси. Исключением являются потоковые ответы, где стандартные настройки прокси могут мешать работе. Именно этот аспект меняется при переходе от демонстрации на ноутбуке к MCP-серверу, работающему на VPS.
location /mcp {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}proxy_buffering off важен, так как nginx по умолчанию буферизует проксируемые ответы, удерживая SSE-события до заполнения буфера или завершения ответа. Спецификация также требует, чтобы серверы отправляли X-Accel-Buffering: no в SSE-ответах, и nginx учитывает этот заголовок, поэтому корректный сервер сам сообщает прокси нужную информацию. Установите эту директиву, так как это та часть, которую вы контролируете.
proxy_read_timeout по умолчанию составляет 60 секунд. Поток subscriptions/listen, который остается неактивным дольше этого времени, закрывается nginx, а не вашим сервером. В результате в логах процесс выглядит исправным, а у клиента обрывается поток. Увеличьте это значение только для location с MCP, а не для всего сервера. Серверам также рекомендуется отправлять строку комментария SSE (строку, начинающуюся с двоеточия) в качестве keep-alive во время периодов простоя, что предотвращает принудительное закрытие потока промежуточными узлами.
Caddy требует меньшей настройки. Он частично буферизует данные по умолчанию для эффективности передачи и немедленно сбрасывает их, если ответ содержит Content-Type: text/event-stream, поэтому потоковая передача работает без дополнительных директив.
mcp.example.com {
reverse_proxy 127.0.0.1:8080 {
health_uri /healthz
health_interval 10s
}
}Обратите внимание, на что указывает проверка работоспособности. Не направляйте активную проверку на конечную точку MCP с помощью GET, так как сервер, реализующий только эту версию, отвечает 405 Method Not Allowed на GET и DELETE, а метод проверки по умолчанию в Caddy — GET. В этом случае прокси пометит полностью исправный бэкенд как нерабочий. Используйте для прокси простой путь, например /healthz, а проверку протокола выполняйте отдельно с помощью POST-запроса.
curl -sS https://mcp.example.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
-d '{"jsonrpc":"2.0","id":"health-1","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'200, содержащий список supportedVersions, означает, что процесс запущен и поддерживает протокол. 404 с ошибкой JSON-RPC -32601 означает, что процесс запущен, но не обслуживает server/discover, который должен реализовывать каждый 2026-07-28-сервер. 400 с -32022 означает, что ваша проверка запросила версию, которую данная сборка не поддерживает; именно это и нужно отслеживать после обновления зависимостей. В open source версии nginx нет активных проверок работоспособности, поэтому используйте пассивные max_fails и fail_timeout для upstream, а проверку протокола выполняйте средствами мониторинга.
Теперь при последовательном перезапуске (rolling restart) вы теряете только текущие запросы. Выполните drain, дождитесь завершения открытых POST-запросов, запустите новый процесс, и клиенты повторно отправят то, что не удалось выполнить. Единственное, что вы все еще прерываете, — это любой открытый поток subscriptions/listen, так как этот поток является активным соединением с конкретным процессом. Отсутствие состояния (statelessness) устранило привязку сессий, но не устранило привязку соединений для потока, который открыт в данный момент, и никакие правила маршрутизации это не исправят. Клиент может отличить ситуации: поток, завершающийся пустым результатом subscriptions/listen, закрыт корректно, а поток, завершившийся без него, — оборван, что может стать для клиента поводом для переподключения.
Кэширование становится возможным впервые. Результаты методов списка теперь содержат ttlMs и cacheScope, а cacheScope: "public" сообщает промежуточным узлам, что они могут кэшировать ответ. Это безопасно только потому, что результаты списков больше не зависят от соединения, что является прямым следствием удаления сессий.
Почему аутентификация меняется при отсутствии сессии
При использовании сессий возникал соблазн выполнить аутентификацию один раз в initialize, а затем считать ID сессии доказательством для всех последующих действий. ID сессии, используемый таким образом, является предъявительским мандатом (bearer credential) без указания аудитории, срока действия и механизма отзыва, выпущенным вашим собственным сервером. Отказ от сессий устраняет этот упрощенный подход, и замена ему оказывается более строгой.
Защищенный MCP сервер выступает в роли сервера ресурсов OAuth 2.1. Каждый HTTP-запрос от клиента должен содержать Authorization: Bearer <access token>, и сервер проверяет токен при каждом запросе. Проверка включает проверку аудитории: сервер должен подтвердить, что токен был выпущен специально для него согласно RFC 8707 (Resource Indicators for OAuth 2.0), и не должен принимать или передавать токены, предназначенные для чего-либо другого. Клиенты запрашивают нужную аудиторию, отправляя параметр resource с каноническим URI сервера.
Обнаружение работает на основе вызова (challenge). Когда поступает запрос без пригодного токена, сервер отвечает 401 Unauthorized.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="files:read"Клиент считывает resource_metadata, получает этот документ (RFC 9728, OAuth 2.0 Protected Resource Metadata, который должны реализовывать MCP серверы), находит сервер авторизации и выполняет процесс получения токена. Валидный токен с недостаточными правами доступа приводит к получению 403 Forbidden с error="insufficient_scope" и областями действия (scopes), необходимыми для данной операции.
Это влечет два последствия для эксплуатации. Проверка токена теперь происходит при каждом запросе, а не один раз за сессию, поэтому сетевой запрос к эндпоинту интроспекции при каждом вызове отразится на задержках: отдавайте предпочтение токенам, которые можно проверить локально по подписи, аудитории и сроку действия, либо кэшируйте результат проверки на короткий промежуток времени, используя токен в качестве ключа. А поскольку нет сессии, хранящей идентификатор, авторизация должна вычисляться на основе токена при каждом вызове. Это более прозрачный подход, чем модель с сессиями, и он соответствует более широкой практике хранения учетных данных вне процесса агента, что описано в хранение секретов вне AI-агента. Области действия (scopes) лишь ограничивают то, что токен может делать после того, как запрос достигнет вас; на машине, где запущен агент, использование плагинов, добавляющих правила разрешений для инструментов и лимиты бюджета определяет, какие вызовы будут выполнены в принципе.
Что верно для этой редакции, а что нет
Все вышесказанное описывает редакцию 2026-07-28. Это описание не является вечным для MCP и не относится к серверу, который вы развернули в прошлом году.
Клиенты и серверы версии 2025-11-25 и более ранних по-прежнему используют модель рукопожатия (handshake). В спецификации эти редакции называются устаревшими (legacy), а редакции с метаданными для каждого запроса — современными (modern). Сервер, поддерживающий только эту редакцию, при встрече со старым клиентом должен отвечать 405 Method Not Allowed на GET или DELETE на конечной точке MCP, игнорировать любой заголовок Mcp-Session-Id, не создавая и не дублируя его, а также игнорировать Last-Event-ID, поскольку потоки не поддерживают возобновление. Сервер, поддерживающий обе эпохи, может обслуживать их на одной конечной точке: запрос с современным заголовком _meta обрабатывается без сохранения состояния, а запрос initialize активирует семантику старых сессий.
Поэтому проверяйте строку редакции, прежде чем полагаться на эту информацию. Если ваш SDK всё ещё отправляет initialize, сессии в вашем развертывании остаются актуальными, и вам по-прежнему нужно решать описанные выше проблемы, связанные с сессиями. То же самое касается клиентской стороны: агент, работающий на вашей машине, например, в конфигурации запуск агента для программирования на VPS, является «безгосударственным» (stateless) в данном смысле только в том случае, если используемая им библиотека поддерживает современную редакцию. Проверьте версию, которую согласовывает ваша среда выполнения, затем ознакомьтесь с соответствующей редакцией спецификации и рассматривайте эту страницу как описание конкретной редакции, а не протокола в целом.
FAQ
Означает ли stateless MCP-сервер, что я не могу ничего сохранять?
Нет. Термин stateless описывает протокол, а не ваше приложение. Базы данных, очереди и кэши работают точно так же, как и раньше. Изменяется лишь то, что состояние, охватывающее несколько вызовов, должно быть привязано к явному идентификатору, который клиент передаёт в каждом запросе, например, к дескриптору, созданному сервером в аргументе инструмента. Чего делать нельзя, так это извлекать контекст из соединения: спецификация гласит, что сервер не должен полагаться на предыдущие запросы в рамках того же соединения для определения возможностей, версии протокола или личности клиента, поскольку каждый запрос содержит эти данные в _meta.
Нужны ли мне по-прежнему sticky sessions на балансировщике нагрузки?
Для обычных запросов — нет. В версии 2026-07-28 каждый POST несёт в себе собственную версию протокола, возможности и учётные данные, поэтому любой реплика может ответить на любой запрос, и round-robin вполне подходит. Единственным долгоживущим объектом остаётся поток ответов subscriptions/listen, который представляет собой одно открытое соединение к одному процессу. Оно завершается, когда завершается процесс, и клиент повторно отправляет subscriptions/listen для его восстановления. Это время жизни соединения, а не сессионная аффинити, и никакое правило маршрутизации не может его предотвратить.
Что случилось с Mcp-Session-Id и потоком HTTP GET?
Оба были удалены в версии 2026-07-28 согласно SEP-2567 и SEP-2575. Сервер, реализующий только эту версию, должен отвечать 405 Method Not Allowed на GET и DELETE на MCP-эндпоинте, а также игнорировать заголовок Mcp-Session-Id, вместо того чтобы возвращать его обратно. Уведомления об изменениях, инициированные сервером, теперь передаются в потоке ответов запроса subscriptions/listen вместо отдельного потока GET. Серверы, которые должны продолжать обслуживать старых клиентов, реализуют поведение предыдущей версии параллельно с этой.
Как выполнять проверку работоспособности (health check) MCP-сервера без рукопожатия?
Используйте два уровня. Направьте активную проверку прокси на обычный HTTP-путь, который обслуживает ваше приложение, поскольку GET к MCP-эндпоинту корректно возвращает 405 и пометит работоспособный бэкенд как неисправный. Затем проверьте сам протокол, отправив POST-запрос server/discover, который должен реализовать каждый 2026-07-28-сервер, и убедитесь, что ответ имеет статус HTTP 200 и содержит версию протокола, которую используют ваши клиенты. Ответ 404 с ошибкой JSON-RPC -32601 означает, что процесс запущен, но не обслуживает этот метод, а ответ 400 с -32022 означает, что запрошенная вами версия не поддерживается данной сборкой.