Skip to main content

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 profile

Cargo 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​

AreaLegacy behavior
Handshakeinitialize / initialized, with serverInfo in InitializeResult
TransportSession-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 resumptionA 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 selectionwith_mcp_version(...) on the server
Server→client requestsCapability-driven push for sampling/createMessage, roots/list, elicitation/create — no MRTR
MacrosThe #[sampling] attribute macro
Logginglogging/setLevel plus with_logging(handle) and a global notifications/message emission path
ToolsThe legacy ToolSchema (not JSON Schema 2020-12)
TasksThe 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
Notificationsping, notifications/roots/list_changed, notifications/elicitation/complete
SubscriptionsThe 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
RequestsNo mandatory _meta keys, no routing-header validation, no resultType
MCP AppsNothing — 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 endpointWhat it gets
With a Last-Event-IDResumes 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 hold404 — answering from whatever stream is at hand would replay what was delivered elsewhere
Without one, nothing connected to the standalone streamThat stream, which is the one carrying server-initiated traffic
Without one, the standalone stream already liveA second stream. The first stays open, and the server-initiated traffic moves onto the newer one
When the session already holds 8 streams429. 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.

Ids that name no stream still resume

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.60.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.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 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​

  • TaskApi methods take &self, and wait_to_completion takes &A. Only an implementation of the trait outside neva is affected.
  • map_sampling is bound by ClientHandler<_, Result<CreateMessageResult, Error>, _>, so a sampling handler can fail. A handler returning a plain CreateMessageResult still 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_batch numbers the requests itself and puts your ids back on the responses.
  • A tasks/update or tasks/cancel the server refuses is an error, not Ok(()), and wait_to_completion stops 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 registry feature joins server-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 failed Client::connect can be retried on the same client. Code that treated a timeout as "probably misconfigured", or rebuilt the whole Client to 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 (poll tasks/get instead); Task::ttl serializes as ttlMs and is now Option<usize>. See Tasks.
  • Results — every success result now carries resultType. If you parse raw responses, read it via Response::result_type().
  • HTTP engine adapters — SseResponse is renamed to StreamResponse (its Status variant to Complete), and handlers::dispatch_post returns StreamResponse<…> 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/unsubscribe are folded into the subscriptions/listen filter. Replace client.subscribe_to_resource(uri) with client.listen(SubscriptionFilter::new().with_resource(uri)), and drop ctx.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 with map_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):