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

MCP 2026-07-28

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

Предыдущее поколение (MCP 2024-11-05 … 2025-11-25) живёт за флагом legacy-spec — там же записаны и пути обновления между релизами.

Сама ревизия спецификации ломает обратную совместимость, и 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.

Отмена — это закрытие потока​

Без сессии notifications/cancelled через Streamable HTTP некуда адресовать. Запрос отменяется закрытием его потока ответа: сервер считает закрытый поток отменой и останавливает обработчик, а клиент никакого уведомления не шлёт. Клиент neva делает так для любого запроса, которого он перестал ждать, — по таймауту, при удалении вызова, при брошенном пакете, при отменённой подписке; см. Таймауты и отмена. Через stdio и легаси-узлу отмену по-прежнему несёт notifications/cancelled.

Заголовки маршрутизации​

Промежуточные узлы маршрутизируют и ограничивают трафик по заголовкам, поэтому заголовки обязаны совпадать с телом запроса:

ЗаголовокОбязателен дляДублирует
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 или аналога. Три его метода — обычные async fn:

    use neva::RequestStateStore;
    use neva::types::Response;

    /// Хранилище, которое ничего не помнит: каждый ретрай выполняет раунд заново.
    struct NoCache;

    impl RequestStateStore for NoCache {
    async fn get(&self, _tag: &str) -> Option<Response> {
    None
    }

    async fn put(&self, _tag: &str, _response: Response, _exp: u64) {}
    }

    reserve — метод, сериализующий одинаковые ретраи финального раунда, — сохраняет реализацию по умолчанию (no-op), а распределённое хранилище переопределяет его настоящей блокировкой.

  3. App::with_notification_bus(<общая шина>) — без неё поток subscriptions/listen, живущий на одном экземпляре, никогда не узнает об изменении, случившемся на другом, и потеря выглядит как «сервер никогда не меняется», а не как сбой доставки. См. Сервер → Запуск нескольких экземпляров.

Привязка состояния к сервису​

Если один и тот же with_request_state_secret делят несколько сервисов, то состояние, выпущенное одним, принимается остальными: оно было привязано к своему запросу и принципалу, но не к сервису. App::with_request_state_audience(<идентичность этого сервиса>) это закрывает, и проверка работает в обе стороны: несовпадение — это InvalidParams, а состояние с указанной аудиторией отклоняется сервером, который её не настраивает.

App::new()
.with_request_state_secret(std::env::var("MCP_STATE_SECRET").unwrap().as_bytes())
.with_request_state_audience("https://weather.example.com/mcp")

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

Протокол: состояние с аудиторией запечатывается под собственной версией (v2., а не v1.), поэтому бинарник, выпущенный до появления этой опции, отклоняет его, вместо того чтобы отбросить неизвестное ему поле — что оставило бы привязку неработающей ровно на том экземпляре, который ещё не обновили. Развёртывание без аудитории продолжает выпускать v1; декодируются обе версии. Состояния, находившиеся в полёте в момент включения опции, отклоняются и истекают в пределах TTL requestState — 5 минут.

Подписки​

Без потока 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 — ctx.tools().add / remove, ctx.prompts().add / remove, ctx.resources().add / remove и ctx.resources().notify_updated — рассылают уведомления во все потоки, которые их просили. На клиенте Client::listen(filter) возвращает дескриптор Subscription, как только сервер подтвердит подписку, а сами уведомления идут в обработчики, зарегистрированные через Client::subscribe и его помощники, — так что существующий клиентский код менять не нужно.

Логи и прогресс не подписочные: они остаются в области запроса и идут по потоку ответа того запроса, который их вызвал. Спецификация проводит через подписку и notifications/tasks, но такой категории в SubscriptionFilter neva пока нет — статус задачи по-прежнему узнают опросом tasks/get.

См. Сервер → Подписки и Клиент → Подписки.

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(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))
}

На стороне клиента раунды происходят внутри tools().call — вызывающий код по-прежнему видит один вызов. Число повторов на слот ограничивается через 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>: тип результата зависит от запрошенного вида, поэтому разбирайте свой тип из значения самостоятельно.

Возможности едут в каждом запросе​

Рукопожатия нет, а значит негде объявить возможность один раз на соединение — поэтому клиент объявляет их в каждом запросе, в его _meta под ключом io.modelcontextprotocol/clientCapabilities:

{
"_meta": {
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": { "form": {} },
"extensions": {
"io.modelcontextprotocol/ui": {
"mimeTypes": ["text/html;profile=mcp-app"]
}
}
}
}
}

Тип на проводе — RequestClientCapabilities: флаги MRTR (elicitation, sampling, roots) плоско, рядом с ними extensions — ровно так, как спецификация пишет ClientCapabilities. Обработчик читает то, что объявила вызывающая сторона этого запроса:

СпрашиваемПолучаем
ctx.client_capabilities()Виды MRTR — на какие input-запросы эта сторона умеет отвечать
ctx.client_extension(id)Настройки, объявленные этой стороной для расширения id, либо None
ctx.supports_apps()Умеет ли эта сторона рендерить MCP Apps — client_extension плюс правило самого расширения
use neva::prelude::*;

#[tool(descr = "Ищет по корпусу")]
async fn search(ctx: Context, query: String) -> String {
let fuzzy = ctx
.client_extension("com.example/search")
.and_then(|settings| settings["fuzzy"].as_bool())
.unwrap_or(false);

format!("ищем {query} (нечёткий поиск: {fuzzy})")
}

#[tokio::main]
async fn main() {
App::new()
.with_options(|opt| opt.with_stdio())
.run()
.await;
}

Объявлением считается само присутствие ключа, но что считать поддержкой расширения — дело самого расширения: MCP Apps требует, чтобы настройки называли типы содержимого, которые клиент рендерит. Это правило применяет supports_apps(), а client_extension() — нет.

Некорректное значение extensions читается как ничего не объявлено, а не заваливает запрос. _meta разбирается целиком, поэтому жёсткая ошибка здесь утянула бы за собой токен прогресса, уровень логирования и все остальные ключи — ради карты, которую neva до появления расширений вообще игнорировала.

Карту в каждый запрос клиент на neva пишет сам, так что объявление доходит до сервера без рукопожатия, а Context читает его обратно.

Недоступно под legacy-spec

Там возможности едут в initialize, а методы доступа для каждого запроса из сборки вырезаются.

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 по reverse-DNS идентификатору и приносящая с собой те методы и метаданные, которые сама определяет. Вместе с neva поставляются два встроенных потребителя.

Задачи​

Задачи появились в neva первыми из двух, объявляются через capabilities.extensions["io.modelcontextprotocol/tasks"]. Возможность представлена пустым объектом — само её объявление и есть декларация, — поэтому opt.with_tasks() не принимает замыкание.

tasks/get — единственный метод опроса, возвращающий DetailedTask; tasks/update отвечает на input-запросы задачи; tasks/cancel подтверждает пустым результатом. tasks/list и tasks/result удалены.

MCP Apps​

MCP Apps (SEP-1865) — первое официальное расширение MCP, объявляется через capabilities.extensions["io.modelcontextprotocol/ui"] и включается вызовом opt.with_apps(). Оно даёт инструменту лицо: HTML-документ ui://, который хост рендерит в песочнице iframe и в который передаёт результат инструмента.

Собственных методов оно не приносит — UI это метаданные на обычных инструментах и ресурсах. Блок _meta.ui на инструменте называет ресурс, который его отрисовывает; блок _meta.ui на этом ресурсе несёт его CSP, разрешения и предпочтения по оформлению. Всё, что называется ui/*, — трафик postMessage между хостом и его iframe, до сервера он не доходит.

Значение capability отличается по направлениям, и это требование спецификации, а не асимметрия neva: сервер объявляет {}, а клиент обязан назвать типы содержимого, которые умеет рендерить (mimeTypes), поэтому with_apps() на клиенте подставляет text/html;profile=mcp-app. Клиент, не назвавший ни одного, поддержку не объявил.

Серверная половина существует только в 2026-07-28; клиентская работает в обоих поколениях. До сервера на 2026-07-28 объявление доходит в _meta каждого запроса, так что обработчик может спросить ctx.supports_apps() и подобрать content под ответ.

Авторизация​

HTTP MCP-сервер — это защищённый ресурс OAuth 2.1: он публикует документ Protected Resource Metadata (RFC 9728) и отвечает на неавторизованный запрос вызовом WWW-Authenticate, который на него указывает, — так что клиент, знающий только URL конечной точки, находит сервер авторизации по 401. Токены несут resource indicator по RFC 8707, и токен, выпущенный для другого ресурса, отклоняется, а не принимается потому, что он валиден.

Поколение также меняет порядок получения client_id: сначала предварительно зарегистрированный, затем Client ID Metadata Document — https-URL, который сервер авторизации разыменовывает, — и только потом Dynamic Client Registration (RFC 7591), которую эта спецификация объявляет устаревшей.

neva реализует половину сервера-ресурса за фичей server-oauth, а клиентскую — за client-oauth, плюс два опциональных расширения:

За пределами текста спецификацииФичаЧто это
Аутентификация клиента private_key_jwtclient-oauth-jwtКлиент подписывает короткоживущее утверждение собственным ключом вместо общего секрета — то, что РЕКОМЕНДУЕТ расширение client credentials
DPoP: токены, привязанные к отправителюclient-oauth-dpopRFC 9449. SEP-1932 не смержен, а в тексте 2026-07-28 DPoP не встречается, поэтому пакет проверок соответствия относит его к расширениям. Выключено по умолчанию и само не включается

Неинтерактивные grants — client credentials (io.modelcontextprotocol/oauth-client-credentials), JWT bearer по RFC 7523 и корпоративный профиль с identity assertion — покрывают развёртывания, где перед браузером никого нет.

См. Сервер → OAuth 2.1 и Клиент → OAuth 2.1.

Удалено в этом поколении​

  • 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. На сервере ctx.resources().subscribe / unsubscribe существуют только под legacy-spec; на клиенте методы остаются скомпилированными для легаси-отката, но на узле 2026-07-28 отвечают MethodNotFound
  • Значения thisServer / allServers у includeContext помечены #[deprecated]; не указывайте поле или используйте none

Новые коды ошибок​

Вариант ErrorCodeКодHTTPПолезная нагрузка data
HeaderMismatch-32020400—
MissingRequiredClientCapability-32021400requiredCapabilities
UnsupportedProtocolVersion-32022400supported / requested

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

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

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