Legacy spec and upgrade paths
This page covers the legacy-spec profile and the release-to-release upgrades.
Day-to-day reference lives on the other pages; version-specific detail lives
here and in the CHANGELOG.
legacy-spec is the opt-in Cargo feature that restores the pre-2026-07-28
protocol generation — MCP 2024-11-05 … 2025-11-25.
[dependencies]
neva = { version = "0.7", features = ["server-full", "legacy-spec"] }
It is a generation switch, not an addition: enabling it compiles the MCP 2026-07-28 surface out. The two generations never coexist in one build.
--all-features selects the legacy profileCargo features are additive, so --all-features turns legacy-spec on and
therefore exercises the legacy profile. The default profile needs an
explicit feature list — e.g. --features "server-full client-full" or
--features full. This is also why docs.rs publishes neva with
features = ["full"] rather than all features.
What legacy-spec restores
| Area | Legacy behavior |
|---|---|
| Handshake | initialize / initialized, with serverInfo in InitializeResult |
| Transport | Session-bound Streamable HTTP: Mcp-Session-Id, session DELETE, SSE GET streams with Last-Event-ID replay — as many at once as the client opens |
| Stream resumption | A dropped POST response stream is resumed once, with a GET carrying Last-Event-ID after the pause the server asked for — only when the server named an id to resume from. Each stream keeps its own cursor and its own reconnection delay, taken from that stream's SSE retry: field rather than a fixed three seconds |
| Version selection | with_mcp_version(...) on the server |
| Server→client requests | Capability-driven push for sampling/createMessage, roots/list, elicitation/create — no MRTR |
| Macros | The #[sampling] attribute macro |
| Logging | logging/setLevel plus with_logging(handle) and a global notifications/message emission path |
| Tools | The legacy ToolSchema (not JSON Schema 2020-12) |
| Tasks | The 2025-11-25 surface: tasks/list and tasks/result (client.tasks().list(cursor) / result(id)), the cancel/list/requests capability sub-tree, with_tasks(|t| …), client-hosted tasks |
| Notifications | ping, notifications/roots/list_changed, notifications/elicitation/complete |
| Subscriptions | The resources/subscribe / resources/unsubscribe RPC pair, ctx.resources().subscribe / unsubscribe, and resource::commands::{SUBSCRIBE, UNSUBSCRIBE} — server-side subscription state instead of a subscriptions/listen stream |
| Requests | No mandatory _meta keys, no routing-header validation, no resultType |
| MCP Apps | Nothing — the server half is compiled out, since the extension rides capabilities.extensions, which this generation has no place for. The client half does work: a legacy initialize carries the declaration on every connection, where a 2026-07-28 one puts it on each request's _meta instead |
Everything else — DI, middleware, content types, JWT auth, TLS, custom HTTP engines, batch requests — is shared between the two generations and behaves the same either way.
Concurrent SSE streams
The spec lets a client "remain connected to multiple SSE streams simultaneously", and asks for event ids assigned "on a per-stream basis, to act as a cursor within that particular stream". A session therefore holds a map of streams, each with its own sender, cursor and replay buffer, and every tracked event id names the stream it belongs to:
id: 0:7
<stream>:<seq>. That is what makes the rest of the rules enforceable.
A GET on the session endpoint | What it gets |
|---|---|
With a Last-Event-ID | Resumes the stream that id names, replayed that stream's backlog past the cursor and nothing that went out on another one |
With a Last-Event-ID naming a stream the session does not hold | 404 — answering from whatever stream is at hand would replay what was delivered elsewhere |
| Without one, nothing connected to the standalone stream | That stream, which is the one carrying server-initiated traffic |
| Without one, the standalone stream already live | A second stream. The first stays open, and the server-initiated traffic moves onto the newer one |
| When the session already holds 8 streams | 429. A disconnected stream is dropped to make room first, so the cap is spent on live ones |
Server-initiated traffic — log notifications included — rides exactly one stream at a time, which is the spec's MUST NOT on delivering a message on more than one. It follows the newest live stream; with nothing live the role stays put, so an ordinary reconnect takes that stream back and is replayed what it missed while the connection was down.
An id in the older per-session shape — <seq>, with no stream — is read as the
standalone stream's cursor while the session holds only that one, so a client
reconnecting across a server upgrade resumes rather than starting over. neva's
own client is unaffected either way: it echoes back whatever id it was handed.
Custom HTTP engines take one signature change:
tracked_event is handed an
EventId instead of a u64.
Talking to a legacy peer without legacy-spec
You usually don't need the flag on the client. neva's default-build client
is dual-mode: it opens with server/discover and, if the peer clearly
does not speak MCP 2026-07-28, falls back to the initialize handshake and
speaks legacy to that peer for the rest of the connection. See
Discovery replaces the handshake.
The server side has no such fallback — it is compile-time pure. A server
that must serve legacy clients needs the legacy-spec build.
Upgrading 0.6.x → 0.7.0
The client's and the Context's calls moved into namespaces, one per MCP method
prefix. The old spellings still compile, each with a deprecation warning naming
its replacement — except one, whose name the new namespace took.
ctx.tools() is the tools namespace
ctx.tools() used to list the server's tools; it now returns the namespace that
lists, finds, calls and changes them. The old call no longer compiles — the
namespace is not a future:
// before
let tools = ctx.tools().await;
// after
let tools = ctx.tools().list().await;
The flat calls are deprecated
On the client:
| 0.6 | 0.7 |
|---|---|
client.list_tools(cursor) | client.tools().list(cursor) — or list_all() for every page |
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) — legacy peers only |
client.list_prompts(cursor) | client.prompts().list(cursor) |
client.get_prompt(name, args) | client.prompts().get(name, args) |
On the server, in a handler:
| 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 only |
The batch builder keeps its flat methods — client.batch().call_tool(..) is
current, not deprecated.
Requests take &self
Client's request methods take &self, so a connected client can be
shared as an Arc<Client>; setup —
connect, map_*, on_*, roots — keeps &mut self. Context's methods take
&self too, so a handler takes plain ctx: Context, and a leftover mut ctx
draws an unused_mut warning.
Two narrower breaks
TaskApimethods take&self, andwait_to_completiontakes&A. Only an implementation of the trait outside neva is affected.map_samplingis bound byClientHandler<_, Result<CreateMessageResult, Error>, _>, so a sampling handler can fail. A handler returning a plainCreateMessageResultstill fits; only code that names the old bound changes.
Behaviour that changed without a signature
- A request the client stops waiting for is cancelled — on a timeout, or when the call is dropped. The server is told, and stops the handler; a request that used to finish quietly after its caller gave up now does not. See Timeouts and Cancellation.
- Closing a request's stream cancels it on a 2026-07-28 HTTP server, as the spec requires; the handler used to run on.
call_batchnumbers the requests itself and puts your ids back on the responses.- A
tasks/updateortasks/cancelthe server refuses is an error, notOk(()), andwait_to_completionstops at it.
And, additively: list_all() on every listing, client.tasks() for the task
methods, and the svir bridge behind the new svir feature.
Upgrading 0.6.0 → 0.6.1
Drop-in — nothing renamed, nothing removed. Two things are worth knowing about afterwards:
- The
registryfeature joinsserver-full. A hand-picked feature list needs it added explicitly. - A transport that cannot start now reports at
connect()/run()instead of as a later timeout, and a failedClient::connectcan be retried on the same client. Code that treated a timeout as "probably misconfigured", or rebuilt the wholeClientto retry, can be simplified.
Upgrading 0.5.x → 0.6.0
Two calls changed. Both fail the build rather than changing behaviour quietly, so there is nothing to audit by eye.
UiResource::with_permissions was renamed
On a UiResource, with_permissions
sets who may read the resource, as it does on every other resource. The
iframe's browser permissions are with_ui_permissions:
// before
app.add_ui_resource("ui://scan/app.html", "scan", html)
.with_permissions(UiPermissions::new().with_camera());
// after
app.add_ui_resource("ui://scan/app.html", "scan", html)
.with_ui_permissions(UiPermissions::new().with_camera());
UiResourceMeta::with_permissions is unaffected.
Registration methods take one more generic parameter
Every registration point carries a handler-shape marker as a generic
parameter — it is what lets one call accept both an
async fn and a plain fn. It is always inferred, so
only a call site that spells its generics out by hand is affected:
// before
app.map_tool::<_, _, (String,)>("greet", greet);
// after — E0107 until the marker is added
app.map_tool::<_, _, (String,), _>("greet", greet);
Affected: App::map_tool, map_prompt, map_resource, map_ui_resource,
map_handler, map_resources, map_completion, Tool::new and Prompt::new.
Client::map_sampling and Client::map_elicitation keep their arity, but their
second parameter is the marker rather than the handler's future type.
Bounds are unaffected. The marker is defaulted on the traits
(ToolHandler<Args, M = marker::Async>), so where F: ToolHandler<Args, Output = R>
keeps its meaning — as does every call site that leaves inference to do its job.
Upgrading 0.4.x → 0.5.0
| You were on 0.4.x with… | Do this |
|---|---|
features = ["proto-2026-07-28-rc"] | Drop the flag. It no longer exists — what it gated is now the default. |
| The old default (no protocol flag) | Add legacy-spec to keep the old wire, or migrate to MCP 2026-07-28. |
Beyond the flag, the code changes worth checking:
- Tasks —
opt.with_tasks()takes no closure;list_tasks()is gone (polltasks/getinstead);Task::ttlserializes asttlMsand is nowOption<usize>. See Tasks. - Results — every success result now carries
resultType. If you parse raw responses, read it viaResponse::result_type(). - HTTP engine adapters —
SseResponseis renamed toStreamResponse(itsStatusvariant toComplete), andhandlers::dispatch_postreturnsStreamResponse<…>instead of a plain response. See Custom HTTP Stack. - Removed calls —
ping,complete_elicitation,on_elicitation_completed,with_logging/set_log_level. - Resource subscriptions —
resources/subscribe/resources/unsubscribeare folded into thesubscriptions/listenfilter. Replaceclient.subscribe_to_resource(uri)withclient.listen(SubscriptionFilter::new().with_resource(uri)), and dropctx.subscribe_to_resource(..)from server handlers — the client owns the subscription now. See Subscriptions. - Sampling & roots — still available, but as
MRTR input-request kinds
and
#[deprecated]. The#[sampling]attribute macro belongs to the legacy push model and is not available in the default build; wire the handler withmap_sampling.
Examples
The legacy variants of the roots and sampling examples live under a
legacy/ sub-directory, each its own Cargo workspace (Cargo unifies
features across members built together, so a shared workspace would flip the
generation for every crate in it):