MCP 2026-07-28
MCP 2026-07-28 is the default protocol generation. A plain neva build
speaks it — there is no opt-in flag — and the rest of this site documents this
generation unless a page says otherwise.
The previous generation (MCP 2024-11-05 … 2025-11-25) lives behind the
legacy-spec feature, which is also where the
upgrade paths between releases are written
down.
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.
Cancellation is closing the stream
With no session to address it to, a notifications/cancelled over Streamable
HTTP has nowhere to go. A request is cancelled by closing its response
stream: the server treats the closed stream as the cancellation and stops the
handler, and the client sends no notification. A neva client does this for any
request it stops waiting for — a timeout, a dropped call, an abandoned batch,
a cancelled subscription — see
Timeouts and Cancellation.
Over stdio, and to a legacy peer, notifications/cancelled still carries it.
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
Three shared resources once you run more than one instance — the first two always, the third as soon as you serve subscriptions:
-
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. Its three methods are plainasync fns:use neva::RequestStateStore;
use neva::types::Response;
/// A store that remembers nothing — every retry re-runs the round.
struct NoCache;
impl RequestStateStore for NoCache {
async fn get(&self, _tag: &str) -> Option<Response> {
None
}
async fn put(&self, _tag: &str, _response: Response, _exp: u64) {}
}reserve— the method that serializes identical final-round retries — keeps its no-op default, and a distributed store overrides it with a real lock. -
App::with_notification_bus(<shared bus>)— without it, asubscriptions/listenstream held on one instance never hears about a mutation that happened on another, and the loss reads as "the server never changes" rather than as a delivery failure. See Server → Running more than one instance.
Binding state to the service
Where several services share one with_request_state_secret, a state minted
by one is a state the others accept — it was bound to its request and
principal, but not to the service.
App::with_request_state_audience(<this service's identity>)
closes that, and the check runs both ways: a mismatch is InvalidParams, and
a state naming an audience is refused by a server that configures none.
App::new()
.with_request_state_secret(std::env::var("MCP_STATE_SECRET").unwrap().as_bytes())
.with_request_state_audience("https://weather.example.com/mcp")
The value has to be identical on every instance of the same service, since a retry may land on any of them.
Wire: an audience-bound state is sealed under its own version (v2. rather
than v1.), so a binary predating the option refuses it instead of dropping
the member it does not know — which would leave the binding unenforced by
exactly the instance still to be upgraded. A deployment that configures no
audience keeps minting v1; both versions decode. States in flight when the
option is turned on are rejected and lapse within the requestState TTL of
5 minutes.
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 the Context mutators — ctx.tools().add / remove,
ctx.prompts().add / remove, ctx.resources().add / remove and
ctx.resources().notify_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.
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(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 tools().call — 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.
Capabilities ride each request
With no handshake, there is nowhere to declare a capability once per
connection — so a client declares on every request, in its _meta under
io.modelcontextprotocol/clientCapabilities:
{
"_meta": {
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": { "form": {} },
"extensions": {
"io.modelcontextprotocol/ui": {
"mimeTypes": ["text/html;profile=mcp-app"]
}
}
}
}
}
The wire type is
RequestClientCapabilities:
the MRTR flags (elicitation, sampling, roots) flat, with extensions
beside them, exactly as the specification spells ClientCapabilities. A handler
reads what the caller of this request declared:
| Ask | Answers |
|---|---|
ctx.client_capabilities() | The MRTR kinds — which input requests this caller can answer |
ctx.client_extension(id) | The settings this caller declared for extension id, or None |
ctx.supports_apps() | Whether this caller can render MCP Apps — client_extension plus the extension's own rule |
use neva::prelude::*;
#[tool(descr = "Searches the corpus")]
async fn search(ctx: Context, query: String) -> String {
let fuzzy = ctx
.client_extension("com.example/search")
.and_then(|settings| settings["fuzzy"].as_bool())
.unwrap_or(false);
format!("searching for {query} (fuzzy: {fuzzy})")
}
#[tokio::main]
async fn main() {
App::new()
.with_options(|opt| opt.with_stdio())
.run()
.await;
}
Presence is the declaration, but what counts as supporting an extension is
the extension's own business: MCP Apps requires its settings to name the content
types the client renders, which is the rule supports_apps() applies and
client_extension() does not.
A malformed extensions value reads as none declared rather than failing the
request. A request's _meta is parsed as one unit, so a hard error there would
take the progress token, the log level and every other key down with it — for a
map neva ignored outright before it read extensions at all.
A neva client writes the map into every request itself, so the declaration
reaches a server with no handshake and Context reads it back.
legacy-specThat profile carries capabilities on initialize instead, and the per-request
accessors are compiled out.
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
New Extension trait: a capability advertised under capabilities.extensions
by a reverse-DNS id, contributing whatever methods and metadata it defines. Two
built-in consumers ship with neva.
Tasks
Tasks was the first of the two to land in neva,
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.
MCP Apps
MCP Apps
(SEP-1865) is the first
official MCP extension, advertised as
capabilities.extensions["io.modelcontextprotocol/ui"] and enabled with
opt.with_apps(). It gives a tool a face: a ui:// HTML document the host
renders in a sandboxed iframe and feeds the tool's result into.
It contributes no methods of its own — a UI is metadata on ordinary tools
and resources. A _meta.ui block on a tool names the resource that renders it;
a _meta.ui block on that resource carries its CSP, permissions and framing
preferences. Everything named ui/* is postMessage traffic between a host and
its iframe and never reaches a server.
The capability's value differs by direction, and that is the specification's
doing rather than an asymmetry in neva: a server advertises {}, while a client
must name the content types it can render (mimeTypes), so with_apps() on the
client fills in text/html;profile=mcp-app. A client that names none has not
declared support.
The server half is 2026-07-28 only; the client half works in
both generations. The declaration reaches a 2026-07-28 server on
each request's _meta, so a handler can ask
whether its caller can render a UI with ctx.supports_apps() and shape its
content accordingly.
Authorization
An HTTP MCP server is an OAuth 2.1 protected resource: it publishes an
RFC 9728 Protected Resource Metadata
document and answers an unauthorized request with a WWW-Authenticate
challenge naming it, so a client that knows only the endpoint URL discovers the
authorization server from the 401. Tokens carry an RFC 8707 resource
indicator, and a token minted for another resource is refused rather than
accepted because it happens to validate.
The generation also reorders how a client obtains a client_id: a
pre-registered one first, then a Client ID Metadata Document — an https URL
the authorization server dereferences — and only then Dynamic Client
Registration (RFC 7591), which this spec deprecates.
neva implements the resource-server half behind server-oauth and the client
half behind client-oauth, plus two opt-in extensions:
| Beyond the spec text | Feature | What it is |
|---|---|---|
private_key_jwt client authentication | client-oauth-jwt | The client signs a short-lived assertion with its own key instead of presenting a shared secret — what the client-credentials extension RECOMMENDS |
| DPoP sender-constrained tokens | client-oauth-dpop | RFC 9449. SEP-1932 is unmerged and DPoP appears nowhere in the 2026-07-28 text, so the conformance suite scores it as an extension. Off by default, never self-enabling |
Non-interactive grants — client credentials
(io.modelcontextprotocol/oauth-client-credentials), RFC 7523 JWT bearer and
the enterprise-managed identity-assertion profile — cover the deployments with
no user in front of a browser.
See Server → OAuth 2.1 and Client → OAuth 2.1.
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 serverctx.resources().subscribe/unsubscribeexist only underlegacy-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
- CHANGELOG and the release notes — what changed in each release.
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/oauth-with-keycloak— the authorization story end to end against a real issuer.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.