MCP 2026-07-28
Начиная с neva 0.5.0 поколение протокола MCP 2026-07-28 используется
по умолчанию. Обычная сборка neva говорит на нём — опционального флага
больше нет, и вся остальная документация описывает именно это поколение,
если на странице не сказано иное.
Предыдущее поколение (MCP 2024-11-05 … 2025-11-25) переехало за флаг
legacy-spec.
Если в вашем Cargo.toml был включён proto-2026-07-28-rc — уберите
флаг, его больше не существует. Если вы полагались на прежнее поведение
по умолчанию, добавьте features = ["legacy-spec"]. См.
Переход на 0.5.0.
Сама ревизия спецификации ломает обратную совместимость, и neva следует за ней, а не замораживается на старом формате. Эта страница объясняет, что это означает на практике.
Discovery вместо рукопожатия
Рукопожатия initialize / initialized больше нет. Клиент открывает
соединение единственным запросом server/discover:
DiscoverResultобъявляетsupportedVersions: string[]— весь набор версий, который поддерживает сервер, — а клиент выбирает одну из них.serverInfoотсутствует в результате discovery. Сервер представляется в_metaкаждого результата, под ключомio.modelcontextprotocol/serverInfo; neva проставляет его на уровне диспетчеризации, аClient::server_infoчитает его оттуда.Client::connect()выполняет discovery за вас.Client::discover()— явный вызов;Client::init()остаётся как псевдоним для обратной совместимости.
Клиент neva работает в двойном режиме: если server/discover отклонён на
уровне протокола (MethodNotFound, InvalidRequest либо ответ не в формате
JSON-RPC или с неизвестным кодом), он откатывается к легаси-рукопожатию
initialize и до конца соединения говорит с этим узлом на старом протоколе.
Сетевые ошибки отката не вызывают. Переключение происходит один раз на
соединение, необратимо и до любого другого трафика. На стороне клиента
with_mcp_version по-прежнему существует, но выбирает только версию, о
которой договаривается откат, — он никогда не заставит server/discover
отклонить корректный сервер 2026-07-28.
HTTP-транспорт без состояния
Транспорт Streamable HTTP теперь работает только по схеме
«запрос — ответ»: никакого Mcp-Session-Id в протоколе, никакого DELETE
сессии и никакого отдельного SSE-потока GET. Серверные уведомления вместе с
ним не исчезли — они переехали на запрос, который клиент открывает именно для
них, см. Подписки.
Каждый запрос объявляет свой контекст сам — сервер без состояния не должен выводить его из предыдущего трафика:
Ключ _meta | Обязателен | Что содержит |
|---|---|---|
io.modelcontextprotocol/protocolVersion | да | Согласованная версия, дублируется заголовком MCP-Protocol-Version |
io.modelcontextprotocol/clientCapabilities | да | Возможности, на которые опирается этот запрос (пустой объект — тоже корректное объявление) |
io.modelcontextprotocol/logLevel | нет | Подписка на логирование в области запроса |
traceparent / tracestate / baggage | нет | Зарезервированные ключи распространения контекста OpenTelemetry |
Запрос без любого из обязательных ключей отклоняется с InvalidParams
(-32602) и HTTP 400; запросы внутри батча проверяются по одному.
Требование относится к сообщению, а не к транспорту, поэтому
Request::required_meta_error публичен, и диспетчер применяет его и для
stdio — специфичен для HTTP только код 400.
Заголовки маршрутизации
Промежуточные узлы маршрутизируют и ограничивают трафик по заголовкам, поэтому заголовки обязаны совпадать с телом запроса:
| Заголовок | Обязателен для | Дублирует |
|---|---|---|
Mcp-Method | каждого запроса | поле method JSON-RPC |
Mcp-Name | tools/call | params.name |
Mcp-Name | resources/read | params.uri |
Mcp-Name | prompts/get | params.name |
Mcp-Name | методов задач | params.taskId |
Mcp-Param-{name} | tools/call | каждый аргумент с аннотацией x-mcp-header |
Отсутствующий или расходящийся заголовок отклоняется с HeaderMismatch
(-32020) и HTTP 400. Значения, не являющиеся безопасным ASCII, — а также
обычные значения, которые можно принять за маркер, — передаются в Base64 за
сентинелом =?base64?...?=, который сервер декодирует перед сравнением.
Уведомление не обязано нести Mcp-Method, но если несёт — должно указать
собственный метод. Заголовки маршрутизации на батче отклоняются целиком:
ни один метод и ни одно имя не описывают батч, поэтому от батча не ждут
дублирования аргументов и не проверяют его на это.
Проверка Origin и Host
Спецификация требует, чтобы локально привязанный сервер проверял эти
заголовки: браузер с готовностью подключится к 127.0.0.1 от имени любой
страницы, чей DNS туда указывает. neva отвечает 403 Forbidden ещё до чтения
тела: при привязке к loopback принимаются только loopback-имена, а
развёртывание за прокси называет свои имена через
HttpServer::with_allowed_origins([...]). См.
Защита от DNS-rebinding.
Обязательный минимум для развёртывания на нескольких экземплярах
Два общих ресурса — оба обязательны, как только экземпляров больше одного:
App::with_request_state_secret(<общий секрет>)— без него повторы, попавшие на другой экземпляр, не смогут расшифроватьrequestState. neva предупреждает об этом при старте. neva запечатываетrequestStateчерез ChaCha20-Poly1305, а не просто подписывает: тег AEAD аутентифицирует данные ровно так же, как это сделал бы HMAC, но подписанное состояние осталось бы читаемым, аctx.memoзаписывает в него значения, вычисленные сервером (ответ внешнего сервиса, назначенную цену, токен нижестоящей системы), чтобы следующий раунд их воспроизвёл. Конфиденциальность здесь ничего не стоит, поэтому секрет обеспечивает и её — обращайтесь с ним как с секретом и ротируйте черезApp::with_request_state_keys.App::with_request_state_store(<общее хранилище>)— без него повтор из-за потерянного ответа заново выполнит обработчик и продублируетon_commit. Хранилище по умолчаниюInMemoryStateStoreживёт в рамках процесса; для продакшена реализуйтеRequestStateStoreповерх Redis или аналога.
Подписки
Без потока GET серверным уведомлениям нужен запрос, на котором они поедут.
Спецификация его даёт: subscriptions/listen — один долгоживущий запрос с
фильтром уведомлений. Он заменяет и поток GET, и пару RPC-методов
resources/subscribe / resources/unsubscribe: подписка на конкретный ресурс
теперь URI внутри фильтра, привязанный к несущему его потоку, а не состояние
на сервере.
--> subscriptions/listen { "notifications": SubscriptionFilter }
<-- notifications/subscriptions/acknowledged { "notifications": …, "_meta": { subscriptionId } }
<-- notifications/tools/list_changed { "_meta": { subscriptionId } }
…
<-- { "id": …, "result": { "resultType": "complete", "_meta": { subscriptionId } } }
SubscriptionFilter целиком построен на явных согласиях —
toolsListChanged, promptsListChanged, resourcesListChanged и
resourceSubscriptions, — а сервер подтверждает запрошенный фильтр,
суженный до объявленных им возможностей, первым же сообщением в потоке.
Каждое сообщение несёт _meta["io.modelcontextprotocol/subscriptionId"],
поэтому по одному каналу может идти несколько подписок.
neva обрабатывает subscriptions/listen сама: серверный обработчик писать не
нужно, а Context::add_tool, remove_tool, add_prompt, remove_prompt,
add_resource, remove_resource и resource_updated рассылают уведомления
во все потоки, которые их просили. На клиенте Client::listen(filter)
возвращает дескриптор Subscription, как только сервер подтвердит подписку, а
сами уведомления идут в обработчики, зарегистрированные через
Client::subscribe и его помощники, — так что существующий клиентский код
менять не нужно.
Логи и прогресс не подписочные: они остаются в области запроса и идут по
потоку ответа того запроса, который их вызвал. Спецификация проводит через
подписку и notifications/tasks, но такой категории в SubscriptionFilter
neva пока нет — статус задачи по-прежнему узнают опросом
tasks/get.
Это появилось в neva 0.5.1 и отменяет замечание, которое neva несла с 0.4.x: «серверные уведомления не работают, опрашивайте сервер». То ограничение описывало release candidate, а не финальную спецификацию.
См. Сервер → Подписки и Клиент → Подписки.
Multi Round-Trip Requests (MRTR)
Обработчик может приостановиться посреди выполнения и запросить данные у
клиента: он вызывает ctx.elicit(key, params), ctx.sample(key, params) или
ctx.list_roots(key) и ждёт ответа. Сервер отвечает input_required; клиент
отвечает и повторяет вызов; обработчик выполняется снова.
Прогресс хранится в запечатанном AEAD блобе requestState, который клиент
возвращает при повторе, — поэтому любой запрос может попасть на любой
экземпляр. Так как обработчик выполняется с самого начала на каждом
раунде, побочные эффекты необходимо оборачивать:
| Примитив | Гарантия |
|---|---|
ctx.memo(key, fut) | Вычисляется один раз; на последующих раундах воспроизводится из requestState |
ctx.once(key, fut) | Выполняется не более одного раза за все раунды |
ctx.on_commit(fut) | Выполняется ровно один раз, когда обработчик доходит до финального результата |
#[tool]
async fn place_order(mut ctx: Context) -> Result<String, Error> {
// Вычисляется один раз; на каждом следующем раунде воспроизводится.
let quote_cents: u32 = ctx.memo("quote", async { Ok(1299) }).await?;
let form = ElicitRequestParams::form(format!(
"Доставка стоит ${:.2}. Укажите данные для отправки:",
quote_cents as f64 / 100.0
))
.with_schema::<Shipping>();
// Первый раунд разворачивает обработчик с `input_required`,
// второй — воспроизводит ответ клиента из `requestState`.
let ship: Shipping = ctx
.elicit("shipping", form.into())
.await?
.content()
.ok_or_else(|| Error::new(ErrorCode::InvalidParams, "доставка отклонена"))?;
// Списание выполнится не более одного раза за все раунды.
ctx.once("charge", async { Ok(()) }).await?;
// Выполнится ровно один раз, на финальном раунде.
let who = ship.full_name.clone();
ctx.on_commit(async move {
tracing::info!("чек отправлен: {who}");
Ok(())
});
Ok(format!("Заказ подтверждён для {}", ship.full_name))
}
На стороне клиента раунды происходят внутри call_tool — вызывающий код
по-прежнему видит один вызов. Число повторов на слот ограничивается через
McpOptions::with_max_mrtr_rounds.
Виды input-запросов: elicitation, sampling, roots
Спецификация не удалила сэмплирование и корневые каталоги. Она убрала их как
серверные запросы к клиенту, управляемые возможностями, и перенесла эту
способность на MRTR в виде видов input-запросов, рядом с elicitation. В
протоколе input-запрос — это по-прежнему конверт { method, params }, где
method играет роль дискриминатора (elicitation/create,
sampling/createMessage, roots/list).
- Elicitation — полноценный, первоклассный вид.
- Сэмплирование и
корневые каталоги — в соответствии с
12-месячным жизненным циклом, заложенным самой спецификацией, —
устарели с момента появления. API помечены
#[deprecated]и существуют для миграции; местам вызова нужен#[allow(deprecated)].
Механика одинакова для всех видов, поэтому once / memo / on_commit
покрывают их бесплатно. ClientMrtrCapabilities содержит elicitation,
sampling и roots; сервер проверяет каждый вид по его собственному
объявлению и отвечает на запрос необъявленного вида ошибкой
MissingRequiredClientCapability (-32021) вместо того, чтобы подвесить
раунд. Объявления аддитивны, поэтому узел, присылающий только
elicitation, по-прежнему разбирается корректно.
Спецификация описывает каждое из них как необязательный объект, а не
булево значение, и содержимое elicitation — это его режимы, так что это
поле — Option<ElicitationModes> с form / url внутри, а не флаг. Клиент,
объявивший {"form": {}}, перечисляет то, что умеет, а голое {} не
называет ни одного режима и потому не исключает ни одного. Что объявил
вызывающий конкретно этого запроса, читайте через
Context::client_capabilities(); см.
Спрашивайте только то, на что вызывающая сторона может ответить.
Обобщённый input-запрос представлен объединением mrtr::InputRequest
(InputRequest::Elicitation(params) / Sampling / Roots), а
mrtr::InputResponses — это HashMap<String, serde_json::Value>: тип
результата зависит от запрошенного вида, поэтому разбирайте свой тип из
значения самостоятельно.
resultType в каждом результате
Дискриминатор обязателен во всех результатах, а не только в продолжениях MRTR. Каждый успешный результат несёт одно из значений:
| Значение | Смысл |
|---|---|
complete | Финальный результат — инструменты, промпты, ресурсы, discovery, автодополнение, … |
input_required | Продолжение MRTR с input-запросами |
task | CreateTaskResult (плоский: Result & Task) |
Значение проставляется централизованно в Response::success, поэтому
покрывает каждую реализацию IntoResponse, включая Json<T> и скалярные.
Уже заданный дискриминатор никогда не перезаписывается — именно так
input_required проходит через ту же воронку; результат, не являющийся
объектом, некуда снабдить полем и передаётся как есть.
Читать его следует через Response::result_type(), который применяет
правило совместимости из спецификации: отсутствующее поле трактуется как
Complete, и так же трактуется любое значение, неизвестное neva.
Кэширование
ttlMs и cacheScope — обязательные члены CacheableResult, а не
опциональные подсказки: они присутствуют в DiscoverResult,
ReadResourceResult и всех четырёх результатах-списках. CacheScope
принимает значения public / private, по умолчанию — private. neva
всегда отправляет оба поля; узел, который их не прислал, всё равно
разбирается.
Инструменты
Tool.input_schema/output_schema— полноценные документы JSON Schema 2020-12 (InputSchemaповерхserde_json::Value); макрос#[tool]формирует их автоматически. Схема публикуется в том виде, в каком её объявили:default(SEP-1034),pattern,examples,$schema,$defs,$ref,additionalProperties,allOf/anyOfиif/then/elseсохраняются дословно (SEP-2106), в том числе ниже корня.- Аргументы извлекаются по имени, поэтому обработчик инструмента и его
опубликованная схема обязаны называть одни и те же аргументы — при
расхождении
App::runотказывается стартовать. ПараметрOption<T>публикуется, но не попадает вrequired. См. Инструменты → Имена аргументов. - Детерминированный порядок списка. Реестры основаны на
BTreeMapи упорядочены по имени, поэтомуtools/listстабилен между вызовами: курсорная пагинация больше не может пропустить или продублировать запись, а промпт-кэши LLM попадают чаще. x-mcp-header. Сервер может аннотировать свойство вinputSchemaинструмента, чтобы аргумент дублировался в заголовкеMcp-Param-{name}. Клиенты обязаны это поддерживать, поэтому клиент neva запоминает аннотации изtools/listи добавляет заголовки приtools/call. Определение, нарушающее ограничения спецификации (имя не является токеном, дубликат, непримитивный тип или свойство, недостижимое статически черезproperties), исключает из списка весь инструмент — так одно неверное определение не сможет изменить то, что отправляет корректное. Работает только для Streamable HTTP; другие транспорты вправе игнорировать аннотацию.
Расширения и задачи
Появился трейт Extension; Задачи — первый
встроенный потребитель, объявляемый через
capabilities.extensions["io.modelcontextprotocol/tasks"]. Возможность
представлена пустым объектом — само её объявление и есть декларация, —
поэтому opt.with_tasks() не принимает замыкание.
tasks/get — единственный метод опроса, возвращающий DetailedTask;
tasks/update отвечает на input-запросы задачи; tasks/cancel подтверждает
пустым результатом. tasks/list и tasks/result удалены.
Удалено в этом поколении
ping(а такжеClient::ping,BatchBuilder::ping)logging/setLevel(а такжеwith_logging/set_log_level) — заменено логированием в области запросаtasks/list,tasks/resultnotifications/roots/list_changednotifications/elicitation/complete(а такжеContext::complete_elicitation,Client::on_elicitation_completed,ElicitationCompleteParams)elicitationIdв URL-elicitation — без серверного сигнала о завершении нечего сопоставлятьwith_mcp_versionна сервере (доступен подlegacy-spec)resources/subscribe/resources/unsubscribeкак RPC-методы — свёрнуты вSubscriptionFilter::resource_subscriptions. На сервереContext::subscribe_to_resource/unsubscribe_from_resourceпереехали заlegacy-spec; на клиенте методы остаются скомпилированными для легаси-отката, но на узле 2026-07-28 отвечаютMethodNotFound- Значения
thisServer/allServersуincludeContextпомечены#[deprecated]; не указывайте поле или используйтеnone
Новые коды ошибок
Вариант ErrorCode | Код | HTTP | Полезная нагрузка data |
|---|---|---|---|
HeaderMismatch | -32020 | 400 | — |
MissingRequiredClientCapability | -32021 | 400 | requiredCapabilities |
UnsupportedProtocolVersion | -32022 | 400 | supported / requested |
Error::with_data прикрепляет полезную нагрузку, определённую
спецификацией. См. Обработка ошибок.
Что почитать дальше
- Заметки о релизе (v0.5.2) и CHANGELOG — полное описание миграции.
examples/mrtr— сквозной пример MRTR: сервер и клиент.examples/subscriptions—subscriptions/listenпо HTTP: сервер и клиент.examples/sampling/examples/roots— сэмплирование и корневые каталоги на подложке MRTR.examples/tasks— переработанное расширение Tasks.cargo doc --features full --open— справочник API для сборки по умолчанию в вашем собственном чекауте. Учтите, что--all-featuresвключаетlegacy-spec, а это компилирует данное поколение из сборки прочь.