Перейти к основному содержимому

MCP 2026-07-28

Начиная с neva 0.5.0 поколение протокола MCP 2026-07-28 используется по умолчанию. Обычная сборка neva говорит на нём — опционального флага больше нет, и вся остальная документация описывает именно это поколение, если на странице не сказано иное.

Предыдущее поколение (MCP 2024-11-05 … 2025-11-25) переехало за флаг legacy-spec.

Обновление с 0.4.x

Если в вашем 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-Nametools/callparams.name
Mcp-Nameresources/readparams.uri
Mcp-Nameprompts/getparams.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.

Обязательный минимум для развёртывания на нескольких экземплярах

Два общих ресурса — оба обязательны, как только экземпляров больше одного:

  1. App::with_request_state_secret(<общий секрет>) — без него повторы, попавшие на другой экземпляр, не смогут расшифровать requestState. neva предупреждает об этом при старте. neva запечатывает requestState через ChaCha20-Poly1305, а не просто подписывает: тег AEAD аутентифицирует данные ровно так же, как это сделал бы HMAC, но подписанное состояние осталось бы читаемым, а ctx.memo записывает в него значения, вычисленные сервером (ответ внешнего сервиса, назначенную цену, токен нижестоящей системы), чтобы следующий раунд их воспроизвёл. Конфиденциальность здесь ничего не стоит, поэтому секрет обеспечивает и её — обращайтесь с ним как с секретом и ротируйте через App::with_request_state_keys.
  2. 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.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-запросами
taskCreateTaskResult (плоский: 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/result
  • notifications/roots/list_changed
  • notifications/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-32020400
MissingRequiredClientCapability-32021400requiredCapabilities
UnsupportedProtocolVersion-32022400supported / requested

Error::with_data прикрепляет полезную нагрузку, определённую спецификацией. См. Обработка ошибок.

Что почитать дальше

  • Заметки о релизе (v0.5.2) и CHANGELOG — полное описание миграции.
  • examples/mrtr — сквозной пример MRTR: сервер и клиент.
  • examples/subscriptionssubscriptions/listen по HTTP: сервер и клиент.
  • examples/sampling / examples/roots — сэмплирование и корневые каталоги на подложке MRTR.
  • examples/tasks — переработанное расширение Tasks.
  • cargo doc --features full --open — справочник API для сборки по умолчанию в вашем собственном чекауте. Учтите, что --all-features включает legacy-spec, а это компилирует данное поколение из сборки прочь.