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

Легаси-спецификация и пути обновления

Эта страница — про профиль legacy-spec и про обновления от релиза к релизу. Повседневный справочник живёт на остальных страницах; всё, что привязано к конкретным версиям, — здесь и в CHANGELOG.

legacy-spec — это опциональный флаг Cargo, возвращающий поколение протокола до 2026-07-28, то есть MCP 2024-11-05 … 2025-11-25.

[dependencies]
neva = { version = "0.7", features = ["server-full", "legacy-spec"] }

Это переключатель поколения, а не добавка: его включение компилирует поверхность MCP 2026-07-28 прочь. Два поколения никогда не сосуществуют в одной сборке.

--all-features выбирает легаси-профиль

Флаги Cargo аддитивны, поэтому --all-features включает legacy-spec и, следовательно, проверяет именно легаси-профиль. Профилю по умолчанию нужен явный список флагов — например, --features "server-full client-full" или --features full. По той же причине docs.rs публикует neva с features = ["full"], а не со всеми флагами.

Что возвращает legacy-spec​

ОбластьЛегаси-поведение
Рукопожатиеinitialize / initialized, с serverInfo в InitializeResult
ТранспортStreamable HTTP с сессиями: Mcp-Session-Id, DELETE сессии, SSE-потоки GET с воспроизведением по Last-Event-ID — столько одновременно, сколько откроет клиент
Возобновление потокаОборвавшийся поток ответа на POST возобновляется один раз — запросом GET с Last-Event-ID после паузы, о которой попросил сервер, и только если сервер назвал идентификатор, с которого продолжать. У каждого потока свой курсор и своя задержка переподключения, взятая из поля SSE retry: этого потока, а не фиксированные три секунды
Выбор версииwith_mcp_version(...) на сервере
Запросы сервер→клиентПуш, управляемый возможностями, для sampling/createMessage, roots/list, elicitation/create — без MRTR
МакросыАтрибутный макрос #[sampling]
Логированиеlogging/setLevel, а также with_logging(handle) и глобальный путь отправки notifications/message
ИнструментыЛегаси-тип ToolSchema (не JSON Schema 2020-12)
ЗадачиПоверхность 2025-11-25: tasks/list и tasks/result (client.tasks().list(cursor) / result(id)), поддерево возможностей cancel/list/requests, with_tasks(|t| …), задачи на стороне клиента
Уведомленияping, notifications/roots/list_changed, notifications/elicitation/complete
ПодпискиПара RPC-методов resources/subscribe / resources/unsubscribe, ctx.resources().subscribe / unsubscribe и resource::commands::{SUBSCRIBE, UNSUBSCRIBE} — состояние подписки на сервере вместо потока subscriptions/listen
ЗапросыНет обязательных ключей _meta, нет проверки заголовков маршрутизации, нет resultType
MCP AppsНичего — серверная половина вырезается, потому что расширение едет в capabilities.extensions, которому в этом поколении нет места. Клиентская половина работает: легаси-initialize несёт объявление на каждом соединении, а в 2026-07-28 оно едет в _meta каждого запроса

Всё остальное — DI, промежуточные обработчики, типы содержимого, JWT-аутентификация, TLS, собственные HTTP-движки, батч-запросы — общее для обоих поколений и ведёт себя одинаково.

Одновременные SSE-потоки​

Спецификация разрешает клиенту «оставаться подключённым к нескольким SSE-потокам одновременно» и требует идентификаторов событий, назначаемых «для каждого потока отдельно, как курсор внутри именно этого потока». Поэтому сессия держит карту потоков — у каждого свой отправитель, свой курсор и свой буфер повтора, — а каждый отслеживаемый идентификатор события называет поток, которому принадлежит:

id: 0:7

<поток>:<номер>. Именно это делает остальные правила проверяемыми.

GET на эндпоинт сессииЧто он получит
С Last-Event-IDВозобновляет тот поток, который назван в идентификаторе, и повторяет буфер только этого потока после курсора — ничего из того, что ушло по другому
С Last-Event-ID, называющим поток, которого у сессии нет404 — ответить любым другим потоком значило бы повторить то, что было доставлено в другом месте
Без него, к отдельному потоку никто не подключёнЭтот самый поток — тот, по которому идёт трафик, инициированный сервером
Без него, отдельный поток уже занятВторой поток. Первый остаётся открытым, а инициированный сервером трафик переезжает на более новый
Когда у сессии уже 8 потоков429. Перед отказом отключённый поток сбрасывается, чтобы освободить место, — лимит тратится на живые

Инициированный сервером трафик (включая уведомления журнала) идёт ровно по одному потоку за раз — это MUST NOT спецификации о доставке сообщения более чем по одному потоку. Он следует за самым новым живым потоком; если живых нет, роль остаётся на месте, поэтому обычное переподключение забирает этот поток обратно и получает повтор того, что было пропущено.

Идентификаторы без потока тоже возобновляются

Идентификатор в старой форме — <номер>, без указания потока — читается как курсор отдельного потока, пока сессия держит только его, поэтому клиент, переподключающийся через обновление сервера, возобновляется, а не начинает заново. Собственный клиент neva не затронут в любом случае: он возвращает тот идентификатор, который ему выдали.

Собственные HTTP-движки меняют одну сигнатуру: tracked_event принимает EventId вместо u64.

Работа с легаси-узлом без legacy-spec​

На стороне клиента флаг обычно не нужен. Клиент в сборке по умолчанию работает в двойном режиме: он открывает соединение через server/discover и, если узел явно не понимает MCP 2026-07-28, откатывается к рукопожатию initialize и до конца соединения говорит с ним на старом протоколе. См. Discovery вместо рукопожатия.

На сервере такого отката нет — он определяется на этапе компиляции. Серверу, который должен обслуживать легаси-клиентов, нужна сборка с legacy-spec.

Обновление 0.6.x → 0.7.0​

Вызовы клиента и Context переехали в пространства имён — по одному на префикс метода MCP. Старые написания по-прежнему компилируются, каждое с предупреждением об устаревании, где названа замена, — кроме одного: его имя заняло новое пространство имён.

ctx.tools() — это пространство имён инструментов​

Раньше ctx.tools() возвращал список инструментов сервера; теперь он возвращает пространство имён, которое их перечисляет, находит, вызывает и изменяет. Старый вызов больше не компилируется — пространство имён не является future:

// было
let tools = ctx.tools().await;

// стало
let tools = ctx.tools().list().await;

Плоские вызовы устарели​

На клиенте:

0.60.7
client.list_tools(cursor)client.tools().list(cursor) — или list_all() для всех страниц
client.call_tool(name, args)client.tools().call(name, args)
client.call_tool_raw(params)client.tools().call_raw(params)
client.task().call_tool(name, args)client.tools().as_task().call(name, args)
client.call_tool_as_task(name, args, ttl)client.tools().as_task().with_ttl(ttl).call(name, args)
client.list_resources(cursor)client.resources().list(cursor)
client.list_resource_templates(cursor)client.resources().templates(cursor)
client.read_resource(uri)client.resources().read(uri)
client.subscribe_to_resource(uri) / unsubscribe_from_resource(uri)client.resources().subscribe(uri) / unsubscribe(uri) — только для легаси-узлов
client.list_prompts(cursor)client.prompts().list(cursor)
client.get_prompt(name, args)client.prompts().get(name, args)

На сервере, в обработчике:

0.60.7
ctx.find_tool(name) / find_tools(names)ctx.tools().find(name) / find_many(names)
ctx.use_tool(tool) / use_tools(tools)ctx.tools().call(tool) / call_all(tools)
ctx.add_tool(tool) / remove_tool(name)ctx.tools().add(tool) / remove(name)
ctx.prompt(name, args)ctx.prompts().get(name, args)
ctx.add_prompt(prompt) / remove_prompt(name)ctx.prompts().add(prompt) / remove(name)
ctx.resource(uri)ctx.resources().read(uri)
ctx.add_resource(res) / remove_resource(uri)ctx.resources().add(res) / remove(uri)
ctx.resource_updated(uri)ctx.resources().notify_updated(uri)
ctx.is_subscribed(&uri)ctx.resources().is_subscribed(&uri)
ctx.subscribe_to_resource(uri) / unsubscribe_from_resource(&uri)ctx.resources().subscribe(uri) / unsubscribe(&uri) — только legacy-spec

Строитель пакетов сохраняет плоские методы — client.batch().call_tool(..) актуален и не устарел.

Запросы принимают &self​

Методы запросов Client принимают &self, поэтому подключённый клиент можно разделять как Arc<Client>; настройка — connect, map_*, on_*, корневые каталоги — по-прежнему требует &mut self. Методы Context тоже принимают &self, так что обработчик принимает просто ctx: Context, а оставшийся mut ctx вызывает предупреждение unused_mut.

Два более узких изменения​

  • Методы TaskApi принимают &self, а wait_to_completion принимает &A. Затронута только реализация трейта вне neva.
  • map_sampling ограничен ClientHandler<_, Result<CreateMessageResult, Error>, _>, поэтому обработчик сэмплирования может завершиться ошибкой. Обработчик, возвращающий просто CreateMessageResult, по-прежнему подходит; меняется только код, который называет старое ограничение явно.

Поведение, изменившееся без смены сигнатур​

  • Запрос, которого клиент перестал ждать, отменяется — по таймауту или при удалении вызова. Сервер получает об этом сигнал и останавливает обработчик; запрос, который раньше тихо доходил до конца после того, как вызывающий сдался, теперь не доходит. См. Таймауты и отмена.
  • Закрытие потока запроса отменяет его на HTTP-сервере 2026-07-28, как требует спецификация; раньше обработчик продолжал работать.
  • call_batch нумерует запросы сам и возвращает ваши идентификаторы на ответы.
  • Отклонённый сервером tasks/update или tasks/cancel — это ошибка, а не Ok(()), и wait_to_completion на ней останавливается.

И добавилось: list_all() у каждого списка, client.tasks() для методов задач и мост svir за новой фичей svir.

Обновление 0.6.0 → 0.6.1​

Обновление бесшовное — ничего не переименовано и не удалено. Знать после него стоит о двух вещах:

  • Компонент registry вошёл в server-full. В собранном вручную списке компонентов его нужно добавить явно.
  • Транспорт, который не смог запуститься, сообщает об этом на connect() / run(), а не позже в виде таймаута, и неудавшийся Client::connect можно повторить на том же клиенте. Код, который считал таймаут признаком «наверное, что-то настроено не так» или пересоздавал Client ради повтора, можно упростить.

Обновление 0.5.x → 0.6.0​

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

UiResource::with_permissions переименован​

На UiResource with_permissions задаёт, кто может прочитать ресурс, как и на любом другом ресурсе. Разрешения браузера для iframe — это with_ui_permissions:

// было
app.add_ui_resource("ui://scan/app.html", "scan", html)
.with_permissions(UiPermissions::new().with_camera());

// стало
app.add_ui_resource("ui://scan/app.html", "scan", html)
.with_ui_permissions(UiPermissions::new().with_camera());

UiResourceMeta::with_permissions это не затрагивает.

Методы регистрации получили ещё один параметр-дженерик​

Каждая точка регистрации несёт маркер формы обработчика как параметр-дженерик — именно он позволяет одному вызову принимать и async fn, и обычную fn. Маркер всегда выводится, поэтому затронуты только вызовы, где дженерики выписаны вручную:

// было
app.map_tool::<_, _, (String,)>("greet", greet);

// стало — E0107, пока маркер не добавлен
app.map_tool::<_, _, (String,), _>("greet", greet);

Затронуты: App::map_tool, map_prompt, map_resource, map_ui_resource, map_handler, map_resources, map_completion, Tool::new и Prompt::new. У Client::map_sampling и Client::map_elicitation арность не изменилась, но их второй параметр — маркер, а не тип future обработчика.

Ограничения не затронуты. У маркера в трейтах есть значение по умолчанию (ToolHandler<Args, M = marker::Async>), поэтому where F: ToolHandler<Args, Output = R> значит то же, что и раньше, — как и любой вызов, оставляющий работу выводу типов.

Обновление 0.4.x → 0.5.0​

Что было в 0.4.xЧто делать
features = ["proto-2026-07-28-rc"]Убрать флаг. Его больше не существует — то, что он включал, теперь работает по умолчанию.
Поведение по умолчанию (без флага протокола)Добавить legacy-spec, чтобы сохранить прежний протокол, либо перейти на MCP 2026-07-28.

Помимо флага стоит проверить следующие изменения в коде:

  • Задачи — opt.with_tasks() не принимает замыкание; list_tasks() удалён (вместо него опрашивайте tasks/get); Task::ttl сериализуется как ttlMs и теперь имеет тип Option<usize>. См. Задачи.
  • Результаты — каждый успешный результат теперь несёт resultType. Если вы разбираете сырые ответы, читайте его через Response::result_type().
  • Адаптеры HTTP-движков — SseResponse переименован в StreamResponse (вариант Status — в Complete), а handlers::dispatch_post возвращает StreamResponse<…> вместо обычного ответа. См. Собственный HTTP-стек. Устаревший псевдоним SseResponse сохраняется на один релиз.
  • Удалённые вызовы — ping, complete_elicitation, on_elicitation_completed, with_logging / set_log_level.
  • Подписки на ресурсы — resources/subscribe / resources/unsubscribe свёрнуты в фильтр subscriptions/listen. Замените client.subscribe_to_resource(uri) на client.listen(SubscriptionFilter::new().with_resource(uri)), а из серверных обработчиков уберите ctx.subscribe_to_resource(..) — подпиской теперь владеет клиент. См. Подписки.
  • Сэмплирование и корневые каталоги — по-прежнему доступны, но как виды input-запросов MRTR и с пометкой #[deprecated]. Атрибутный макрос #[sampling] относится к легаси-модели с серверным пушем и в сборке по умолчанию недоступен — регистрируйте обработчик через map_sampling.

Примеры​

Легаси-варианты примеров с корневыми каталогами и сэмплированием лежат в подкаталоге legacy/, каждый — отдельное рабочее пространство Cargo (Cargo объединяет флаги для участников, собираемых вместе, поэтому общее рабочее пространство переключило бы поколение для всех крейтов в нём):