Skip to main content

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:

  • 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.

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:

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​

Three shared resources once you run more than one instance — the first two always, the third as soon as you serve subscriptions:

  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. Its three methods are plain async 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.

  3. App::with_notification_bus(<shared bus>) — without it, a subscriptions/listen stream 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:

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

AskAnswers
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.

Not available under legacy-spec

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

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​

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 textFeatureWhat it is
private_key_jwt client authenticationclient-oauth-jwtThe 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 tokensclient-oauth-dpopRFC 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 (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 ctx.resources().subscribe / unsubscribe exist only under 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​