Skip to main content

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.

Upgrading from 0.4.x

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:

  • DiscoverResult advertises supportedVersions: string[] — the whole set the server speaks — and the client picks one.
  • serverInfo is not in the discovery result. Servers identify themselves in every result's _meta, under io.modelcontextprotocol/serverInfo; neva stamps it at the dispatch seam and Client::server_info reads 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 keyRequiredCarries
io.modelcontextprotocol/protocolVersionyesThe negotiated version, mirrored by the MCP-Protocol-Version header
io.modelcontextprotocol/clientCapabilitiesyesThe capabilities this request relies on (an empty object is a valid declaration)
io.modelcontextprotocol/logLevelnoOpt into request-scoped logging
traceparent / tracestate / baggagenoReserved 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:

HeaderRequired onMirrors
Mcp-Methodevery requestthe JSON-RPC method
Mcp-Nametools/callparams.name
Mcp-Nameresources/readparams.uri
Mcp-Nameprompts/getparams.name
Mcp-Nametask methodsparams.taskId
Mcp-Param-{name}tools/calleach 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:

  1. App::with_request_state_secret(<shared secret>) — without it, cross-instance retries fail to decrypt requestState. neva warns at startup if you forget. neva seals requestState with 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, and ctx.memo writes 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 via App::with_request_state_keys.
  2. App::with_request_state_store(<shared store>) — without it, lost-response retries re-run the handler and double-fire on_commit. The default InMemoryStateStore is per-process; implement RequestStateStore over 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.

New in neva 0.5.1

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:

PrimitiveGuarantee
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:

ValueMeaning
completeA terminal result — tools, prompts, resources, discover, completion, …
input_requiredAn MRTR continuation carrying input requests
taskA 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_schema are full JSON Schema 2020-12 documents (InputSchema over serde_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/anyOf and if/then/else survive 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::run refuses to start when they disagree. An Option<T> parameter is published but not required. See Tools → Argument Names.
  • Deterministic listing order. The registries are BTreeMap-backed and ordered by name, so tools/list is 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's inputSchema property so the argument is mirrored into an Mcp-Param-{name} header. Clients must honor it, so neva's client records the annotations from tools/list and attaches the headers on tools/call. A definition that breaks the spec's constraints (non-token name, duplicate, non-primitive type, or a property not statically reachable through properties) 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 (and Client::ping, BatchBuilder::ping)
  • logging/setLevel (and with_logging / set_log_level) — replaced by request-scoped logging
  • tasks/list, tasks/result
  • notifications/roots/list_changed
  • notifications/elicitation/complete (and Context::complete_elicitation, Client::on_elicitation_completed, ElicitationCompleteParams)
  • elicitationId on URL elicitation — with no server-initiated completion signal there is nothing to correlate
  • with_mcp_version on the server (available under legacy-spec)
  • resources/subscribe / resources/unsubscribe as RPC methods — folded into SubscriptionFilter::resource_subscriptions. On the server Context::subscribe_to_resource / unsubscribe_from_resource move behind legacy-spec; on the client the methods stay compiled for the legacy fallback but reject a 2026-07-28 peer with MethodNotFound
  • includeContext's thisServer / allServers are #[deprecated]; omit the field or use none

New error codes

ErrorCode variantCodeHTTPdata payload
HeaderMismatch-32020400
MissingRequiredClientCapability-32021400requiredCapabilities
UnsupportedProtocolVersion-32022400supported / requested

Error::with_data attaches the spec-defined payloads. See Error Handling.

Where to look next