MCP 2026-07-28
Starting with neva 0.5.0, MCP 2026-07-28 is the default protocol
generation. A plain neva build speaks it — there is no opt-in flag any
more, and the rest of this site documents this generation unless a page
says otherwise.
The previous generation (MCP 2024-11-05 … 2025-11-25) moved behind the
legacy-spec feature.
If your Cargo.toml enabled proto-2026-07-28-rc, drop the flag — it no
longer exists. If you relied on the old default, add
features = ["legacy-spec"]. See Migrating to 0.5.0.
The spec revision is itself a breaking change, and neva follows it rather than freezing on the old wire. This page is the narrative for what that means in practice.
Discovery replaces the handshake
The initialize / initialized handshake is gone. A client opens with a
single server/discover request:
DiscoverResultadvertisessupportedVersions: string[]— the whole set the server speaks — and the client picks one.serverInfois not in the discovery result. Servers identify themselves in every result's_meta, underio.modelcontextprotocol/serverInfo; neva stamps it at the dispatch seam andClient::server_inforeads it from there.Client::connect()runs discovery for you.Client::discover()is the explicit call;Client::init()remains as a back-compat alias.
Neva's client is dual-mode: if server/discover is rejected at the wire
phase (MethodNotFound, InvalidRequest, or a non-JSON-RPC / unknown-code
reply), it falls back to the legacy initialize handshake and speaks legacy
to that peer for the rest of the connection. Network-level failures do not
trigger the fallback. The switch is per-connection, monotonic, and decided
before any other traffic. On the client, with_mcp_version still exists but
only selects which legacy version the fallback negotiates — it can never
make server/discover reject a valid 2026-07-28 server.
Stateless HTTP transport
The Streamable HTTP transport is request/response only: no Mcp-Session-Id
on the wire, no session DELETE, and no standalone SSE GET stream.
Server-initiated pushes did not go away with it — they moved onto a request
the client opens for exactly that purpose, see
Subscriptions.
Every request declares its own context, because a stateless server must never infer it from earlier traffic:
_meta key | Required | Carries |
|---|---|---|
io.modelcontextprotocol/protocolVersion | yes | The negotiated version, mirrored by the MCP-Protocol-Version header |
io.modelcontextprotocol/clientCapabilities | yes | The capabilities this request relies on (an empty object is a valid declaration) |
io.modelcontextprotocol/logLevel | no | Opt into request-scoped logging |
traceparent / tracestate / baggage | no | Reserved OpenTelemetry propagation keys |
A request missing either mandatory key is rejected with InvalidParams
(-32602) and HTTP 400; requests inside a batch are checked one by one.
The requirement is on the message, not the transport, so
Request::required_meta_error is public and the dispatch seam enforces it on
stdio too — only the 400 is HTTP's.
Routing headers
Intermediaries route and rate-limit on headers, so the headers must agree with the body:
| Header | Required on | Mirrors |
|---|---|---|
Mcp-Method | every request | the JSON-RPC method |
Mcp-Name | tools/call | params.name |
Mcp-Name | resources/read | params.uri |
Mcp-Name | prompts/get | params.name |
Mcp-Name | task methods | params.taskId |
Mcp-Param-{name} | tools/call | each x-mcp-header-annotated argument |
A missing or disagreeing header is rejected with HeaderMismatch
(-32020) and HTTP 400. Values that are not safe ASCII — and plain values
that would be mistaken for the marker — travel Base64 behind the
=?base64?...?= sentinel, which the server decodes before comparing.
A notification is not required to carry Mcp-Method, but one that does must
state its own method. Routing headers on a batch are rejected outright:
no single method or name describes a batch, so a batched call is neither
expected to mirror its arguments nor checked for having done so.
Origin and Host validation
The spec requires a locally bound server to validate these headers, because
a browser will happily connect to 127.0.0.1 on behalf of any page whose
DNS points there. Neva answers 403 Forbidden before reading the body: on a
loopback bind only loopback names are accepted, and a deployment behind a
proxy names its own with HttpServer::with_allowed_origins([...]). See
DNS-Rebinding Protection.
Deployment must-do for multi-instance HTTP
Two shared resources, both required once you run more than one instance:
App::with_request_state_secret(<shared secret>)— without it, cross-instance retries fail to decryptrequestState. neva warns at startup if you forget. neva sealsrequestStatewith ChaCha20-Poly1305 rather than merely signing it: the AEAD tag authenticates the blob exactly as an HMAC would, but a signed state would still be readable, andctx.memowrites server-computed values (an upstream response, a quoted price, a downstream token) into it for the next round to replay. Confidentiality costs nothing here, so the secret upholds it too — treat it as a secret and rotate it viaApp::with_request_state_keys.App::with_request_state_store(<shared store>)— without it, lost-response retries re-run the handler and double-fireon_commit. The defaultInMemoryStateStoreis per-process; implementRequestStateStoreover Redis or similar for production.
Subscriptions
With no GET stream, server-initiated notifications need a request to ride
on. The spec gives them one: subscriptions/listen, a single long-lived
request carrying a notification filter. It replaces both the GET stream and
the resources/subscribe / resources/unsubscribe RPC pair — a per-resource
subscription is now a URI in the filter, scoped to the stream that carries it,
rather than server-side state.
--> subscriptions/listen { "notifications": SubscriptionFilter }
<-- notifications/subscriptions/acknowledged { "notifications": …, "_meta": { subscriptionId } }
<-- notifications/tools/list_changed { "_meta": { subscriptionId } }
…
<-- { "id": …, "result": { "resultType": "complete", "_meta": { subscriptionId } } }
SubscriptionFilter is opt-in throughout — toolsListChanged,
promptsListChanged, resourcesListChanged and resourceSubscriptions —
and the server acknowledges the requested filter narrowed to the
capabilities it advertises, as the first message on the stream. Every
message carries _meta["io.modelcontextprotocol/subscriptionId"], so one
channel can carry several subscriptions.
Neva handles subscriptions/listen itself: there is no server handler to
write, and Context::add_tool, remove_tool, add_prompt, remove_prompt,
add_resource, remove_resource and resource_updated fan out to the
streams that asked for them. On the client, Client::listen(filter) returns a
Subscription handle once the server acknowledges, and the notifications
themselves flow to the handlers registered with Client::subscribe and
friends — so existing client code needs no change.
Logging and progress are not subscribable: they stay request-scoped and
ride the response stream of the request that triggered them. The spec routes
notifications/tasks through a subscription too, but that category is not in
neva's SubscriptionFilter yet — task status is still learned by polling
tasks/get.
This landed in neva 0.5.1, and it retracts the note neva carried since 0.4.x that "server-initiated notifications are inert; clients poll instead" — that limitation described the release candidate, not the final spec.
See Server → Subscriptions and Client → Subscriptions.
Multi Round-Trip Requests (MRTR)
A handler can pause mid-execution to ask the client for input. It calls
ctx.elicit(key, params), ctx.sample(key, params), or
ctx.list_roots(key) and awaits the answer. The server replies
input_required; the client answers and retries; the handler runs again.
Progress lives in the AEAD-sealed requestState blob the client echoes on
retry, so any request can land on any instance. Because handlers re-run
from the top each round, side effects must be wrapped:
| Primitive | Guarantee |
|---|---|
ctx.memo(key, fut) | Computed once; replayed from requestState on later rounds |
ctx.once(key, fut) | Runs at most once across all rounds |
ctx.on_commit(fut) | Runs exactly once, when the handler reaches its final result |
#[tool]
async fn place_order(mut ctx: Context) -> Result<String, Error> {
// Fetched once; replayed on every later round.
let quote_cents: u32 = ctx.memo("quote", async { Ok(1299) }).await?;
let form = ElicitRequestParams::form(format!(
"Shipping is ${:.2}. Please provide your shipping details:",
quote_cents as f64 / 100.0
))
.with_schema::<Shipping>();
// Round 1 unwinds the handler with `input_required`;
// round 2 replays the client's answer from `requestState`.
let ship: Shipping = ctx
.elicit("shipping", form.into())
.await?
.content()
.ok_or_else(|| Error::new(ErrorCode::InvalidParams, "shipping was declined"))?;
// The charge runs at most once across all rounds.
ctx.once("charge", async { Ok(()) }).await?;
// Runs exactly once, on the final round.
let who = ship.full_name.clone();
ctx.on_commit(async move {
tracing::info!("receipt sent to {who}");
Ok(())
});
Ok(format!("Order confirmed for {}", ship.full_name))
}
On the client side the round-trips happen inside call_tool — the
caller still sees a single call. Cap re-issues per slot with
McpOptions::with_max_mrtr_rounds.
Input-request kinds: elicitation, sampling, roots
The spec did not delete sampling and roots. It removed them as
capability-driven server→client requests and re-homed the ability onto
MRTR as input-request kinds, alongside elicitation. On the wire an input
request is still a { method, params } envelope; method is the
discriminator (elicitation/create, sampling/createMessage,
roots/list).
- Elicitation is first-class.
- Sampling and roots
are — matching the spec's own 12-month lifecycle — deprecated on
arrival. The APIs carry
#[deprecated]and exist for migration; call sites need#[allow(deprecated)].
The mechanics are identical across kinds, so once / memo / on_commit
cover them for free. ClientMrtrCapabilities carries elicitation,
sampling, and roots; the server gates each kind on its own declaration
and answers a request for an undeclared kind with
MissingRequiredClientCapability (-32021) instead of stalling the
round-trip. The declarations are additive, so a peer that only sends
elicitation still decodes.
The spec spells each one as an optional object, not a boolean, and
elicitation's contents are its modes — so that field is an
Option<ElicitationModes> with form / url inside rather than a flag. A
client declaring {"form": {}} is stating a list of what it can do, and a
bare {} names no mode and therefore rules none out. Read what the caller
of this request declared with Context::client_capabilities(); see
Ask only for what the caller can answer.
The Rust API for a generalized input request is the mrtr::InputRequest
union (InputRequest::Elicitation(params) / Sampling / Roots), and
mrtr::InputResponses is HashMap<String, serde_json::Value> — the result
type depends on the requested kind, so deserialize your own type out of the
value.
resultType on every result
The discriminator is mandatory on results, not just on MRTR continuations. Every success result carries one:
| Value | Meaning |
|---|---|
complete | A terminal result — tools, prompts, resources, discover, completion, … |
input_required | An MRTR continuation carrying input requests |
task | A CreateTaskResult (flat: Result & Task) |
It is stamped centrally in Response::success, so it covers every
IntoResponse impl including Json<T> and the scalar ones. An existing
discriminator is never overwritten, which is how input_required survives
the same funnel; a non-object result has nowhere to put the field and is
passed through.
Read it with Response::result_type(), which applies the spec's
compatibility rule: an absent field reads as Complete, and so does any
value neva does not recognize.
Caching
ttlMs and cacheScope are mandatory members of CacheableResult
rather than optional hints — on DiscoverResult, ReadResourceResult, and
all four list results. CacheScope is public / private, defaulting to
private. neva always emits both; a peer that omits them still parses.
Tools
Tool.input_schema/output_schemaare full JSON Schema 2020-12 documents (InputSchemaoverserde_json::Value); the#[tool]macro emits them automatically. A schema is published the way it was declared —default(SEP-1034),pattern,examples,$schema,$defs,$ref,additionalProperties,allOf/anyOfandif/then/elsesurvive verbatim (SEP-2106), below the root as well.- Arguments are extracted by name, so a tool's handler and its published
schema have to name the same ones —
App::runrefuses to start when they disagree. AnOption<T>parameter is published but notrequired. See Tools → Argument Names. - Deterministic listing order. The registries are
BTreeMap-backed and ordered by name, sotools/listis stable across calls — cursor pagination can no longer skip or repeat entries, and LLM prompt caches hit more often. x-mcp-header. A server may annotate a tool'sinputSchemaproperty so the argument is mirrored into anMcp-Param-{name}header. Clients must honor it, so neva's client records the annotations fromtools/listand attaches the headers ontools/call. A definition that breaks the spec's constraints (non-token name, duplicate, non-primitive type, or a property not statically reachable throughproperties) drops that tool from the listing, so one bad definition cannot change what a good one sends. Streamable HTTP only — other transports may ignore it.
Extensions and Tasks
New Extension trait; Tasks is the first built-in
consumer, advertised as capabilities.extensions["io.modelcontextprotocol/tasks"].
The capability is an empty object — advertising it is the declaration — so
opt.with_tasks() takes no closure.
tasks/get is the single polling method and returns a DetailedTask;
tasks/update answers a task's input requests; tasks/cancel acknowledges
with an empty result. tasks/list and tasks/result are removed.
Removed in this generation
ping(andClient::ping,BatchBuilder::ping)logging/setLevel(andwith_logging/set_log_level) — replaced by request-scoped loggingtasks/list,tasks/resultnotifications/roots/list_changednotifications/elicitation/complete(andContext::complete_elicitation,Client::on_elicitation_completed,ElicitationCompleteParams)elicitationIdon URL elicitation — with no server-initiated completion signal there is nothing to correlatewith_mcp_versionon the server (available underlegacy-spec)resources/subscribe/resources/unsubscribeas RPC methods — folded intoSubscriptionFilter::resource_subscriptions. On the serverContext::subscribe_to_resource/unsubscribe_from_resourcemove behindlegacy-spec; on the client the methods stay compiled for the legacy fallback but reject a 2026-07-28 peer withMethodNotFoundincludeContext'sthisServer/allServersare#[deprecated]; omit the field or usenone
New error codes
ErrorCode variant | Code | HTTP | data payload |
|---|---|---|---|
HeaderMismatch | -32020 | 400 | — |
MissingRequiredClientCapability | -32021 | 400 | requiredCapabilities |
UnsupportedProtocolVersion | -32022 | 400 | supported / requested |
Error::with_data attaches the spec-defined payloads. See
Error Handling.
Where to look next
- Release notes (v0.5.2) and the CHANGELOG — the full migration narrative.
examples/mrtr— end-to-end MRTR server + client.examples/subscriptions—subscriptions/listenover HTTP, server + client.examples/sampling/examples/roots— the sampling and roots kinds on the MRTR substrate.examples/tasks— the realigned Tasks extension.cargo doc --features full --open— the API reference for the default build in your own checkout. Note that--all-featuresturnslegacy-specon, which compiles this generation out.