Легаси-спецификация и пути обновления
Эта страница — про профиль 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.6 | 0.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.6 | 0.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
объединяет флаги для участников, собираемых вместе, поэтому общее рабочее
пространство переключило бы поколение для всех крейтов в нём):