SSD Nodes Learn 🎉 VPS от $4.99/мес
Руководства Matt ConnorАвтор: Matt Connor · Обновлено 2026-08-08

Что изменилось в stateless MCP-серверах

Ревизия MCP от 2026-07-28 удалила сессии и рукопожатие initialize. Узнайте, как это влияет на настройку обратного прокси, проверки работоспособности, таймауты и аутентификацию.

Что такое stateless MCP-сервер

Stateless MCP-сервер не хранит состояние для каждого клиента между запросами. Каждый запрос содержит версию протокола, возможности клиента и учетные данные, необходимые серверу для ответа, поэтому любой процесс на любой машине может обработать любой запрос. MCP (Model Context Protocol, формат передачи данных, который используют агенты для обращения к инструментам) сделал это правилом в версии 2026-07-28, где были удалены рукопожатие initialize и лежащая в его основе HTTP-сессия.

В этом заключается основной смысл эксплуатации. Сервер, который не хранит данные о клиенте, может находиться за обычным балансировщиком нагрузки без привязки сессий (session affinity), перезапускаться во время развертывания без разрыва связи с клиентами и работать как четыре идентичных процесса вместо одного. Сервер с поддержкой сессий не способен на это без дополнительных механизмов.

Model Context Protocol — это stateless-протокол: вся информация, необходимая для обработки запроса, содержится в самом запросе. Сервер обрабатывает каждый запрос независимо; состояние не должно выводиться из предыдущих запросов, даже если они были переданы через то же соединение или поток.

Stateless не означает, что ваш сервер вообще ничего не хранит. Ваша база данных, очередь и кэш по-прежнему доступны. Это означает, что протокол не переносит состояние через соединение, поэтому сервер не должен воспринимать соединение, процесс или открытый сокет как признак того, что «этот клиент находится в середине диалога».

Что было удалено в редакции 2026-07-28

2026-07-28 — это текущая редакция спецификации по состоянию на август 2026 года. По сравнению с 2025-11-25, из неё удалены пять элементов, которые ранее обеспечивали поддержку сессий.

  • Запрос initialize и уведомление notifications/initialized. Рукопожатие (handshake) полностью отсутствует (SEP-2575).
  • Заголовок Mcp-Session-Id и завершение сессии с помощью HTTP DELETE (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, чтобы восстановить его.

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

Развертывание: обратный прокси, тайм-ауты, проверки работоспособности

Конечная точка 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-агента.

Что верно для этой редакции, а что нет

Все вышесказанное описывает редакцию 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 означает, что запрошенная вами версия не поддерживается данной сборкой.