On MCP 2026-07-28, change notifications have one delivery path. The client opens a long-lived subscriptions/listen stream that says what it wants to hear about, and the server pushes to it. Without sessions, there is no other channel a notification could travel on.

Every modern server answers subscriptions/listen; there is nothing to enable. Your code only has to say when something changed:

ConduitMcp.notify_tools_changed()
ConduitMcp.notify_prompts_changed()
ConduitMcp.notify_resources_changed()
ConduitMcp.notify_resource_updated("file:///project/config.json")

Each returns :ok and reaches the matching streams on the local node (see Multi-node). A server that never calls them simply sends nothing.

The request

POST / HTTP/1.1
Content-Type: application/json
Accept: text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: subscriptions/listen

{"jsonrpc": "2.0", "id": 7, "method": "subscriptions/listen",
 "params": {
   "notifications": {
     "toolsListChanged": true,
     "resourceSubscriptions": ["file:///project/config.json"]
   },
   "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
             "io.modelcontextprotocol/clientCapabilities": {}}}}

Accept must include text/event-stream. A listen has no JSON form, so without it the request is answered with HTTP 406 and -32600.

The filter

FieldValueDeliversHonoured when
toolsListChangedbooleannotifications/tools/list_changedthe server advertises tools
promptsListChangedbooleannotifications/prompts/list_changedthe server advertises prompts
resourcesListChangedbooleannotifications/resources/list_changedthe server advertises resources
resourceSubscriptionsup to 1 000 URIsnotifications/resources/updatedthe server advertises resources
taskIdsup to 1 000 task idsnotifications/tasksConduitMcp.Ext.Tasks is enabled (Tasks Extension)
  • false, or a field the server doesn't honour, subscribes to nothing and is left out of the acknowledgment. For example, an Endpoint without prompts omits promptsListChanged.
  • Unknown fields, such as a newer revision's or an extension's this server doesn't enable, are ignored and left out of the acknowledgment.
  • A field of the wrong type, a notifications value that isn't an object, or more than 1 000 URIs or task ids is -32602. It comes back as a plain JSON-RPC error, and no stream is opened.

Resource subscriptions

  • Exact match. A resource topic matches its URI exactly: notify_resource_updated("file:///a/b") does not reach a stream subscribed to "file:///a".
  • Authorized when the stream opens. Each URI is checked against the resource's scope, the same check resources/read uses. A URI the caller may not read is dropped from the acknowledgment silently, exactly as if it had not been requested. The acknowledgment never reveals more than the caller could read.
  • Shared across endpoints. Topics are node-wide, not per endpoint: notify_resource_updated/1 reaches every stream subscribed to that URI, whichever Server or Endpoint admitted it. Each endpoint's own scope check and hook decide which of its callers may subscribe.
  • The subscribe hook applies. When the server defines handle_subscribe_resource/2, each URI that passes the scope check is also passed to it, exactly as a legacy resources/subscribe is, and only URIs it answers {:ok, _} for are honoured. A refused URI is dropped silently, like a scope miss. Use the hook for per-user rules that scope can't express (for example, user://{id} only for the caller's own id).
  • Without the hook, the scope check alone decides. This differs from legacy, which refuses resources/subscribe outright without the hook: legacy subscriptions are delivered only by the author's own hook, while modern subscriptions are delivered by the library from ConduitMcp.Subscriptions.notify_resource_updated/1, so modern resources.subscribe is always advertised. Define the hook whenever update timing of a readable resource must not reach a caller.
  • Deduplicated. Duplicate URIs are collapsed. If no URI survives, the field is left out of the acknowledgment.

On the wire

The response is a Server-Sent Events stream. Each frame is one message event carrying one JSON-RPC message.

  1. Acknowledgment first. notifications/subscriptions/acknowledged lists only the fields honoured, each with the value honoured.
  2. Notifications as the server publishes them, with :\r\n keep-alive comments in between.
  3. Every message is tagged. Each one carries _meta["io.modelcontextprotocol/subscriptionId"], set to the listen request's id.
event: message
data: {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{"notifications":{"toolsListChanged":true,"resourceSubscriptions":["file:///project/config.json"]},"_meta":{"io.modelcontextprotocol/subscriptionId":7}}}

:

event: message
data: {"jsonrpc":"2.0","method":"notifications/resources/updated","params":{"uri":"file:///project/config.json","_meta":{"io.modelcontextprotocol/subscriptionId":7}}}

The stream registers its topics before it sends the acknowledgment, so nothing published after the acknowledgment is missed.

How a stream ends

Who ends itWhyWhat is sent
Clientdisconnectsnothing
Server:max_stream_lifetime reachednotifications/cancelled for the listen id, then the closing result
Serverthe OAuth bearer token's exp passed, if that comes before the lifetimethe same
Server:listen_idle_timeout with no notification deliveredthe same
Servera newer listen of the same principal arrived at :max_listens_per_principal; this stream is that principal's oldestthe same

On a server-side close, the order is fixed: first notifications/cancelled (with requestId set to the listen id and a reason), then the subscriptions/listen result (resultType: "complete", tagged with the subscriptionId), and then the stream ends. Clients reconnect with a new listen.

A stream never outlives the credential that opened it. Only OAuth tokens carry an exp; for other strategies the lifetime cap applies. Keep-alives don't count as activity for the idle timeout. The idle timeout is checked on each keep-alive tick, so it can fire up to :keep_alive_interval late.

When a stream's process ends, it removes its registry entries and its stream slot. A process that is killed leaks neither.

Limits

Transport optionDefaultOver the limit
:max_listens_per_principal4admitted; the principal's oldest stream is ended gracefully (reason :superseded)
:max_streams1_000HTTP 503, JSON-RPC -32000, no stream
:listen_idle_timeout30 minutesgraceful close
:max_stream_lifetime1 hourgraceful close
:keep_alive_interval15_000 ms—
{Bandit,
 plug: {ConduitMcp.Transport.StreamableHTTP,
        server_module: MyApp.MCPServer,
        max_listens_per_principal: 2,
        listen_idle_timeout: :timer.minutes(10)},
 port: 4001}
  • Per-principal cap. The principal is ConduitMcp.Principal.rate_limit_key/1: the authenticated id, otherwise the client's address bucket. This cap is always on and needs no Hammer backend. It is never answered 429: a client's dead streams are only noticed on their next write, so a client that opens a listen per run would lock itself out. Instead the new listen is admitted and the principal's oldest stream gets notifications/cancelled, then the closing result, then closes. Admission is serialized per principal, so even a burst never holds more than the cap live; another principal's streams are never touched.

  • Server-wide cap. :max_streams is shared with streamed tools/call responses. A full server is a capacity problem, not something the client did, so it is answered 503 (and supersedes nothing).

  • Mailbox bound. A stream writes each notification synchronously, so a client that stops reading parks its stream in the socket send. Once a stream holds config :conduit_mcp, :listen_max_queue undelivered notifications (default 1_000), further notifications skip that stream (and only that stream) and emit [:conduit_mcp, :subscription, :drop]. The stream is not closed for it; it ends through the bounds above.

These options must be positive integers, or init/1 raises ArgumentError. See Rate Limiting for how these caps relate to the Hammer limiters.

Where listen is not served

Capabilities

On the modern era, server/discover sets listChanged: true on each of tools, prompts and resources that the server advertises, and subscribe: true on resources. Legacy initialize capabilities don't change: there is no legacy delivery path for list-changed notifications, and legacy resources.subscribe is advertised only when the server defines handle_subscribe_resource/2.

Telemetry

A listen whose stream opened emits these events instead of [:conduit_mcp, :request, :stop], because its duration is a lifetime, not a latency. A listen refused with 406 or 503 emits neither.

EventMeasurementsMetadata
[:conduit_mcp, :subscription, :start]system_time, resource_subscriptions (count)filters, server_module
[:conduit_mcp, :subscription, :stop]duration (native units)filters, server_module, reason (:client_closed, :max_lifetime, :token_expired, :idle_timeout, :superseded)
[:conduit_mcp, :subscription, :drop]message_queue_lentopic (:tools, :prompts, :resources, :resource or :task)

filters is a list of atoms: :tools_list_changed, :prompts_list_changed, :resources_list_changed, :resource_subscriptions and :task_ids. reason is :client_closed, :max_lifetime, :token_expired or :idle_timeout. The events never include URIs, ids or principals.

Multi-node

The registry behind the streams, ConduitMcp.Subscriptions, is node-local: a notification reaches only the streams served by the node that published it. On a cluster, broadcast the change on your own PubSub and call ConduitMcp.notify_* on every node:

# wherever the change happens
Phoenix.PubSub.broadcast(MyApp.PubSub, "mcp", {:resource_updated, uri})

# a process subscribed to "mcp" on every node
defmodule MyApp.McpRelay do
  use GenServer

  def start_link(_), do: GenServer.start_link(__MODULE__, nil, name: __MODULE__)

  @impl true
  def init(nil) do
    :ok = Phoenix.PubSub.subscribe(MyApp.PubSub, "mcp")
    {:ok, nil}
  end

  @impl true
  def handle_info(:tools_changed, state) do
    ConduitMcp.notify_tools_changed()
    {:noreply, state}
  end

  def handle_info({:resource_updated, uri}, state) do
    ConduitMcp.notify_resource_updated(uri)
    {:noreply, state}
  end
end

Phoenix.PubSub.broadcast/3 also delivers to the local node, so publish only through PubSub. Don't also call notify_* directly, or local streams get the notification twice.