Skip to main content

Subscriptions

Under MCP 2026-07-28 a client opts into server-initiated notifications with a single long-lived subscriptions/listen request carrying a filter. On the server side there is no handler to write: neva answers subscriptions/listen itself and fans your existing Context calls out to every stream whose filter admits them.

The accepted filter is the requested one narrowed to the advertised capabilities, so what a server announces is exactly what a client may subscribe to:

use neva::prelude::*;

#[tokio::main]
async fn main() {
App::new()
.with_options(|opt| opt
.with_http(|http| http.bind("127.0.0.1:3000").with_endpoint("/mcp"))
.with_tools(|tools| tools.with_list_changed())
.with_prompts(|prompts| prompts.with_list_changed())
.with_resources(|res| res.with_list_changed().with_subscribe()))
.run()
.await;
}
CapabilityEnablesNotification
tools.listChangedtoolsListChangednotifications/tools/list_changed
prompts.listChangedpromptsListChangednotifications/prompts/list_changed
resources.listChangedresourcesListChangednotifications/resources/list_changed
resources.subscriberesourceSubscriptionsnotifications/resources/updated

A category a client asks for but the server does not advertise is dropped from the acknowledgment rather than refused. The subscription still opens, and the client learns immediately that those types will never arrive.

Your handlers do not change​

The Context mutators fan out on their own — every existing call site keeps working, and a server that never had a subscription now feeds one:

use neva::prelude::*;

// Emits `notifications/tools/list_changed` to every stream that asked for it
ctx.tools().add(Tool::new("greet", || async { "hello" })).await?;
let _ = ctx.tools().remove("greet").await?;

// `notifications/prompts/list_changed`
let _ = ctx.prompts().remove("summarize").await?;

// `notifications/resources/list_changed`
ctx.resources().add(Resource::new("res://config", "config")).await?;
let _ = ctx.resources().remove("res://config").await?;

// `notifications/resources/updated` — only to streams listing this URI
ctx.resources().notify_updated("res://config").await?;

The registry lives on the shared McpOptions, so a Context belonging to any in-flight request reaches every live stream — a notification is not confined to the request that produced it.

Log and progress notifications are not subscribable and keep their request-scoped behavior: they ride the response stream of the request that triggered them — see Logging → Delivery.

notifications/tasks is a subscription category in the spec, but it is not in SubscriptionFilter yet, so Context::task_changed has no stream to reach in a default build and clients learn task status by polling tasks/get.

Asking who is listening​

ctx.resources().is_subscribed answers from the live streams, so you can skip expensive local work nobody will receive:

use neva::prelude::*;

let resources = ctx.resources();
if resources.is_subscribed(&"res://config".into()) {
// re-render the snapshot, refresh the cache — the expensive part,
// worth skipping when nobody on this node is listening
}

// Publish either way — the subscription filters route it.
resources.notify_updated("res://config").await?;
is_subscribed is node-local

It can only answer for the instance running the handler. Under a notification bus a subscriber elsewhere may be waiting for exactly the update this instance would skip.

That is why notify_updated does not pre-check it: it publishes unconditionally and lets the filters route the result, which is what they already do. Use is_subscribed to skip work, never to decide whether to notify.

Running more than one instance​

A subscriptions/listen stream is a socket held open by exactly one process, and the stateless transport pins nothing to an instance — so the subscriber and the request that mutates the server routinely land on different ones:

client --- subscriptions/listen ------------> instance A   (stream held here)
client --- tools/call (mutates the tools) --> instance B (ctx.tools().add)
instance B has no subscribers
instance A's subscriber hears nothing

The subscriber was told its filter was accepted, so the loss reads as "the server never changes" rather than as a delivery failure.

App::with_notification_bus(..) closes that gap: each instance publishes what it produces and delivers what it receives to the streams it holds.

use neva::prelude::*;
use neva::app::notification_bus::{BusNotification, NotificationBus};
use neva::shared::Stream;

struct RedisBus { /* … */ }

impl NotificationBus for RedisBus {
async fn publish(&self, notification: BusNotification) {
// hand off to a background connection
}

fn subscribe(&self) -> impl Stream<Item = BusNotification> + Send + 'static {
// every instance's notifications, this one's own included
}
}

App::new()
.with_notification_bus(RedisBus { /* … */ })
.with_options(|opt| opt.with_default_http())
.run()
.await;

The subscriber table stays node-local by construction: half of every entry is a handle to a socket on one node, so a shared registry could not deliver anyway. What is pluggable is the distribution. neva ships the trait and the local default; shared implementations (Redis pub/sub, NATS, Postgres LISTEN/NOTIFY) live outside the crate, as for RequestStateStore.

The contract​

RuleWhy
No echo suppression — subscribe must yield this instance's own publishesLocal delivery goes through that same stream and only through it. A bus that hides an instance's messages from itself silences that instance's own subscribers. Redis pub/sub, NATS and tokio::sync::broadcast all echo by default
At-most-once is enoughA subscription whose buffer is full drops the notification with a warning rather than blocking the request that produced it. Redelivery after an instance dies buys nothing — subscriptions are not resumable by spec, and a client whose stream drops re-sends subscriptions/listen
publish is awaited inside the producing requestA slow bus slows that request down. Prefer an implementation that hands off to a background connection over one that waits for a round trip
A stream that ends stops delivery for goodAn implementation that can reconnect should do so inside the stream rather than end it

BusNotification serializes as the notification body it describes ({"method": …, "params": …}), so a bus that ships JSON can hand it straight to serde_json in both directions. It carries nothing about the instance that produced it or the subscription it lands on — the receiving instance matches it against its own filters and stamps each copy with that stream's subscription id.

Nothing changes without one. There is no bus by default, notifications go straight to this instance's subscribers, and a single-instance server pays no channel, no allocation and no task for the trait's existence.

Three things, not two

A multi-instance stateless deployment serving subscriptions now configures with_request_state_secret, with_request_state_store and with_notification_bus.

What goes on the wire​

--> subscriptions/listen  { "notifications": SubscriptionFilter }
<-- notifications/subscriptions/acknowledged { "notifications": …, "_meta": { subscriptionId } }
<-- notifications/tools/list_changed { "_meta": { subscriptionId } }
…
<-- { "id": …, "result": { "resultType": "complete", "_meta": { subscriptionId } } }

The acknowledgment is always the first message on the stream, and every message carries _meta["io.modelcontextprotocol/subscriptionId"] so a client sharing one channel across several subscriptions can demultiplex them.

How a subscription ends​

TriggerWhere it applies
notifications/cancelled for the listen requeststdio
The client closing the streamStreamable HTTP
Transport closeboth
Server shutdownboth — after a graceful empty result

Over HTTP a notifications/cancelled travels on its own POST and proves nothing about who opened the stream, so closing the response body is the sound mechanism there — and the client sees Cancelled rather than a final result.

Graceful close on shutdown

The spec says a server ending a subscription on its own initiative SHOULD send the empty result first, so a client can tell an orderly end from a dropped connection.

Shutdown is two-phase for that reason. The signal ends the subscriptions and waits until every result has reached the outbound channel; only then is the transport torn down, and run waits for the transport writers before returning so the result survives a runtime dropped right behind it. App::with_shutdown_drain(..) caps that wait (2 seconds by default) and is skipped outright when no subscription is open, so a server that never uses them shuts down as fast as one that cannot.

Transports​

TransportHow the stream is carried
Streamable HTTPThe listen POST gets a text/event-stream reply and the notifications land on its body. This is the third way a POST becomes a stream, alongside a logLevel and a progressToken — and unlike those two it needs no tracing feature
stdioMessages interleave on stdout

Under legacy-spec​

The RPC pair comes back and the server owns the subscription again: ctx.resources().subscribe(uri), ctx.resources().unsubscribe(&uri) and resource::commands::{SUBSCRIBE, UNSUBSCRIBE} exist only under legacy-spec. In the default build, drop the subscribe calls from your handlers — the client owns the subscription now, and there is nothing for the server to add.

Learn By Example​