Конвейер

MCP стал stateless: что ломается и как проверить свой сервер

Опубликовано 4 авг. 2026 г.13 мин чтенияСредний уровень
Что вы получите
  • Одна команда, которая говорит, на какой ревизии протокола ваш сервер
  • Четыре места, где новая спецификация ломает интеграцию без ошибки
  • Таблица кодов ответа для отладки - какой код что значит
  • Проверка, которая отличает старый сервер от нового по одному полю
Применить за 15 мин
Экономит 120 ч
Средний уровень
4просмотров

Что случилось 28 июля?

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

Предыдущая стабильная ревизия датирована 2025-11-25. Восемь месяцев без изменений для протокола, вокруг которого выросла целая индустрия интеграций, - это долго, и накопленное вышло одним куском.

Что убрали:

  • рукопожатие initialize и уведомление notifications/initialized;
  • заголовок Mcp-Session-Id и вместе с ним протокольные сессии;
  • методы ping и logging/setLevel, уведомление notifications/roots/list_changed;
  • возобновляемость потока: заголовок Last-Event-ID и идентификаторы событий.

Что добавили:

  • server/discover - метод обнаружения, который сервер обязан реализовать;
  • subscriptions/listen - один долгоживущий поток вместо подписок на ресурсы;
  • шаблон многораундовых запросов MRTR: сервер возвращает input_required, клиент повторяет запрос с данными;
  • обязательное поле resultType в каждом результате;
  • поля кэширования ttlMs и cacheScope в списочных методах.

Каждое изменение в спецификации снабжено номером предложения и ссылкой на обсуждение: SEP-2567, SEP-2575, SEP-2322, SEP-2663, SEP-2596, SEP-2549. Проверяйте формулировки по ним, а не по пересказам.

Почему «я обновил SDK» ещё не значит «я мигрировал»?

Источник первый - дефолт SDK. Документ миграции TypeScript SDK говорит прямо:

Ничто в v2 по умолчанию не кладёт в провод ни одного байта ревизии 2026-07-28: собранные вручную Client, Server и McpServer продолжают говорить на протоколе эпохи 2025, под который были написаны.

- Model Context Protocol, Гайд миграции TypeScript SDK v2 на ревизию 2026-07-28 (перевод с английского)

Режим legacy стоит по умолчанию. Чтобы клиент попробовал новую ревизию, ему нужен явный versionNegotiation: { mode: 'auto' }. Локальная проверка на паре свежих пакетов дала такой вывод:

DEFAULT (no versionNegotiation) => era: legacy | tools: ping_tool
mode:'auto'                     => era: modern | tools: ping_tool
mode:{pin:'2026-07-28'}         => era: modern | tools: ping_tool

Инструменты работают во всех трёх случаях. Разница видна только через getProtocolEra().

Источник второй - имя пакета в npm. Мажорную двойку получило новое семейство, а привычное имя осталось на единице:

ПакетВерсияОпубликован
@modelcontextprotocol/sdk1.30.027.07, 17:56 UTC
@modelcontextprotocol/core2.0.027.07, 23:55 UTC
@modelcontextprotocol/client2.0.027.07, 23:55 UTC
@modelcontextprotocol/server2.0.027.07, 23:55 UTC
@modelcontextprotocol/node2.0.027.07, 23:55 UTC
@modelcontextprotocol/inspector2.0.028.07, 07:33 UTC

Рядом лежат ещё два пакета: codemod для автоматической миграции кода и server-legacy - замороженная копия старого транспорта. При установке последнего npm печатает предупреждение: пакет существует только ради миграции и новых возможностей не получит.

Источник третий - константа внутри самих пакетов v2. Она осталась легаси-словарём:

LATEST_PROTOCOL_VERSION      = 2025-11-25
SUPPORTED_PROTOCOL_VERSIONS  = ["2025-11-25","2025-06-18","2025-03-26","2024-11-05","2024-10-07"]

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

Как узнать, на какой ревизии ваш сервер?

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

bash
curl -s -X POST https://ваш-сервер/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":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'

В ответе придёт supportedVersions со списком ревизий.

Способ второй, для сервера с обычным stdio. Он же самый показательный. Спросите сервер новой ревизией и посмотрите, что он ответит:

bash
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \
  | npx -y ваш-mcp-сервер 2>/dev/null | head -1

На живом сервере это дало вот что:

json
{"result":{"protocolVersion":"2025-11-25","serverInfo":{"name":"Playwright","version":"1.62.0-alpha"}}}

Клиент попросил 2026-07-28. Сервер ответил 2025-11-25, и рукопожатие прошло успешно. Ни ошибки, ни предупреждения: сервер молча согласовал ту ревизию, которую знает сам. Клиент, который не читает поле protocolVersion из ответа, узнает об этом по странному поведению через неделю.

Здесь же прячется ирония, ради которой стоит запомнить эту команду. Спросить сервер таким способом можно только методом initialize - тем самым рукопожатием, которое новая ревизия удаляет. Проверка работает ровно до тех пор, пока ваш сервер живёт на старой эре.

Способ третий - два бинарных признака транспорта. Современный сервер отвечает 405 на GET и DELETE к своей точке входа, старый отдал бы на GET поток событий:

bash
curl -s -o /dev/null -w "%{http_code}\n" -X GET    https://сервер/mcp   # 405
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE https://сервер/mcp   # 405

Способ четвёртый - из кода клиента. getProtocolEra() возвращает modern или legacy и остаётся единственным способом узнать правду о собственном подключении.

Способ пятый - инспектор. В версии 2.0.0 эра подключения показана в интерфейсе переключателем Protocol Era. Это самый наглядный путь, если сервер уже подключён к инспектору.

Почему server/discover говорит не всю правду?

Если вы пишете клиент, который по supportedVersions решает «этот сервер старый, работать не буду», вы отсеете сервер, который вас прекрасно обслужил бы по старым правилам. Проверять надо обе стороны: сначала современный запрос, потом откат на рукопожатие.

Что ломается молча?

Первое. Спецификация прямо описывает, как ведёт себя старый сервер, получивший современный запрос:

Сервер может отклонить запрос ошибкой на своё усмотрение, промолчать или даже обработать метод, неоднозначный между эрами, по правилам старой эры.

- Model Context Protocol, Спецификация 2026-07-28, раздел о версионировании (перевод с английского)

То есть tools/call может просто выполниться, но по старым правилам. Лечится обязательной пробой server/discover первым запросом: она даёт детерминированный отказ вместо неопределённости.

Второе. Отсутствие поля трактуется как успех:

Ради обратной совместимости с серверами прежних ревизий, которые не присылают resultType, клиенты ОБЯЗАНЫ считать отсутствующий resultType значением «complete».

- Model Context Protocol, Спецификация 2026-07-28 (перевод с английского)

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

Третье. Заголовки старого мира выбрасываются без единого слова:

Заголовок Mcp-Session-Id в запросе - игнорировать, идентификаторы сессий не выпускать и не отражать обратно. Заголовок Last-Event-ID - игнорировать; потоки не возобновляемы.

- Model Context Protocol, Спецификация 2026-07-28, транспорт Streamable HTTP (перевод с английского)

Старый клиент отправляет идентификатор сессии, ошибки не получает - и всё состояние, которое он на эту сессию вешал, тихо перестаёт существовать.

Четвёртое - дефолт legacy в собственном SDK, с которого начинался разбор. Он же самый частый.

Что ломается громко?

СитуацияHTTPКодЧто смотреть
Нет _meta в теле запроса400-32602заголовок обещает новую ревизию, а конверта в теле нет
Заголовок не совпал с телом400-32020Mcp-Method называет один метод, тело - другой
Версия не поддержана400-32022в data.supported придёт список того, что сервер умеет
Метод неизвестен404-32601метод удалён из ревизии либо не реализован
Не хватает возможности400-32021в data.requiredCapabilities перечислено, чего не хватило
GET или DELETE к точке входа405-признак современного сервера

Диапазон -32020..-32099 теперь зарезервирован за самой спецификацией, а -32000..-32019 остаётся за реализациями. Отдельно поменялся код «ресурс не найден»: он переехал с -32002 на -32602, и это единственное изменение кодов, которое ломает работающее ветвление на стороне клиента.

Кто с кем совместим?

СочетаниеИсход
Обе стороны в одной эреработает
Одна из сторон умеет обе эрыработает, с откатом на рукопожатие
Новый клиент против старого серверападает
Старый клиент против нового серверападает

Отличить старый сервер от современного помогает одно поле. Старый на неподдержанную версию отвечает кодом -32000 и человекочитаемой строкой, где список версий перечислен текстом. Современный отвечает -32022 и кладёт список в data.supported. Спецификация прямо запрещает завязывать откат на конкретный код ошибки: смотреть надо на наличие поля data.supported, а не на число.

Куда девать состояние, которое лежало в сессиях?

Инженер Кевин Ридль из Wavect перечисляет, что искать в своём коде перед миграцией: карты сессий, липкие cookie, объекты транспорта, переиспользуемые между запросами, списки инструментов на соединение и хранилища событий для возобновляемых потоков. Его вывод короче любого чек-листа:

Именно поэтому «мы не храним сессии MCP» - недостаточная проверка перед миграцией.

- Кевин Ридль, Wavect, Руководство по миграции MCP-сервера на stateless (перевод с английского)

Спецификация про requestState высказывается жёстко: сервер обязан считать его вводом, контролируемым атакующим, и защищать целостность, а клиент не имеет права разбирать и менять его содержимое.

Второй капкан - хэндлы без проверки владельца. Если один инструмент выдал идентификатор корзины, а другой принимает его на веру, вы получили уязвимость по дизайну: любой, кто угадает или перехватит идентификатор, поработает с чужими данными. Хэндл в новой схеме - это допуск, и обращаться с ним надо как с допуском: проверять на каждом вызове, что предъявитель имеет на него право.

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

Почему ретрай теперь может списать деньги дважды?

Формулировка спецификации не оставляет вариантов: идентификатор JSON-RPC у повтора обязан отличаться от исходного, потому что это независимые запросы. Ридль формулирует последствие одной строкой:

Повтор после оборванного потока не должен списать, отправить или удалить дважды.

- Кевин Ридль, Wavect, Руководство по миграции MCP-сервера на stateless (перевод с английского)

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

Что стало с Roots, Sampling и Logging?

Замены выглядят так: вместо Roots - передавать файлы и каталоги обычными параметрами инструмента или адресами ресурсов; вместо Logging - писать в стандартный поток ошибок либо использовать OpenTelemetry, для которого ревизия отдельно описала соглашения о передаче контекста трассировки.

Здесь и прячется самое недооценённое. Сервер, который раньше переиспользовал модель клиента, после миграции превращается в самостоятельного потребителя платного API. В бюджете появляется новая строка, и посчитайте её до переезда, а не после первого счёта. Сколько именно уходит на токены и почему счёт растёт быстрее ожиданий, мы разбирали в тексте про контекстное окно.

Отдельная деталь: поле, которым заменили logging/setLevel, помечено устаревшим в той же самой ревизии, где его ввели.

Сколько у вас времени на самом деле?

Работающие интеграции в августе не рассыплются: обе эры живут параллельно, а SDK по умолчанию держат старую. Но и год в запасе - иллюзия. Сократить окно короче года политика разрешает только при активной угрозе безопасности с опубликованным advisory, зато удалить возможность сразу после истечения срока могут без отдельного повода.

Пятнадцать минут на проверку своей стороны сегодня, миграция в план на квартал.

А протокол вообще нужен?

Эйсо Кант, глава Poolside, в подкасте Latent.Space 23 июля высказался против самой связки MCP и вызова инструментов и уточнил, что держится этой позиции около двух лет. Его прогноз: через год мы не увидим ни одного системного промпта, набитого двумя-тремя десятками инструментов. Возразить на это просто: его же компания продолжает поддерживать и вызов инструментов, и MCP.

Со стороны безопасности в трекере спецификации 31 июля появился разбор с семью претензиями к дизайну - от инъекции через описания инструментов до теневых имён, которые, по мнению автора, молча конфликтуют между серверами. Он подчёркивал, что говорит о дефектах спецификации, а не реализаций. Через два дня обсуждение закрыл сопровождающий проекта, отклонив разбор как сгенерированный ИИ пересказ уже известных векторов атаки. То есть претензии не проигнорировали - их сочли не новыми.

Обе позиции полезно держать в голове, но ни одна из них не отменяет практической задачи: интеграции, которые у вас уже работают, написаны под протокол, и его основа изменилась.

Что сделать сегодня

  1. Спросите каждый сервер запросом initialize с новой ревизией и запишите, какую ревизию он назвал в ответе.
  2. Проверьте свой клиент через getProtocolEra(). Если там legacy при свежих пакетах, вы ещё в старой эре.
  3. Найдите в коде карты сессий, липкие cookie и переиспользуемые объекты транспорта. Составьте список того, что придётся вынести в явные аргументы.
  4. Проверьте, что списания, отправки и удаления защищены ключом идемпотентности.
  5. Если пользуетесь Sampling - посчитайте, во сколько обойдётся прямой доступ к API провайдера.

Источники

Статья оказалась полезной?
Автор
Илья Лапшов
Автор

Другие статьи

Как запустить двух агентов сразу и не смешать правки: git worktree

Два агента в одном рабочем каталоге делят индекс git, и коммит одного уносит файлы другого. Разбираем механику и разводим сессии по отдельным worktree.

19 мин

Что ваш агент может достать на машине: проверка за 10 минут

Песочница кодового агента выключена по умолчанию, не покрывает чтение файлов и заканчивается там, где начинается контейнерный демон. Разбираем, как проверить свой периметр.

15 мин

Права агента в Claude Code: что класть в deny и где он не держит

Claude Code permissions выглядят как защита, пока не посмотришь, из чего они сделаны. Разбираем порядок вычисления правил, четыре формы записи пути, семь каналов, по которым файл доезжает до контекста, и тот слой, где никакие правила на вашей машине уже не помогают.

25 мин

Почему агент забывает контекст и как устроено контекстное окно

«Агент забыл, о чём мы говорили» - за одной фразой стоят четыре разных механизма, и лечатся они по-разному. Разбираем, что занимает контекстное окно, почему заявленный миллион токенов не равен доступному и сколько на самом деле стоит русский текст.

15 мин