Local delivery of legacy-era MCP server notifications.
MCP revisions 2024-11-05 through 2025-11-25 deliver
notifications/tools/list_changed, notifications/prompts/list_changed,
notifications/resources/list_changed, and notifications/resources/updated
on the connection itself. Nothing on the wire correlates them with a request,
so the client decides locally which process wants them. A listener is that
decision: ExMCP.Client.subscribe_notifications/3 registers a filter for a
subscriber process, and the client forwards every matching notification to
it as
{:ex_mcp_notification, listener, method, params}This is the legacy counterpart of ExMCP.Client.listen/3, not a substitute
for it. Both accept the same filter vocabulary, but the guarantees differ:
listen/3 (MCP 2026-07-28) | subscribe_notifications/3 (legacy) | |
|---|---|---|
| Wire request | subscriptions/listen, acknowledged by the server | None for list changes; resources/subscribe per URI |
| Correlation | Server-supplied subscription id on every event | None; the client matches on method and URI |
| Reconnect | Re-opened by the subscription process with a resync snapshot | Resource URIs re-subscribed; no snapshot |
| Task events | taskIds filter | Not available |
| Owner | An ExMCP.Client.Subscription process | State inside the client |
A listener can only be registered on a legacy peer. On a modern peer
ExMCP.Client.subscribe_notifications/3 returns {:error, :use_listen}.
Filter
The filter uses the notification-filter keys of MCP 2026-07-28 so hosts can share one vocabulary across eras:
"toolsListChanged","promptsListChanged","resourcesListChanged"(booleans) enable the matchinglist_changednotification;"resourceSubscriptions"(a list of URIs) enablesnotifications/resources/updatedfor those URIs only.
The requested filter is authoritative. A resources/updated notification for
a URI that is not listed is never delivered, even if the server sends it,
and a disabled list_changed category is dropped. "taskIds" is rejected
because legacy peers have no task notifications.
Resource subscriptions
Every URI in "resourceSubscriptions" needs a server-side subscription. The
client sends resources/subscribe once per URI, shared across all listeners
that name it, and resources/unsubscribe when the last listener naming it
goes away. A failed resources/subscribe fails the whole registration with
{:error, {:subscribe_failed, uri, reason}} and rolls back any subscription
the call had already made. All of those wire operations for one client run
one at a time in a small worker process, and each listener releases exactly
the URIs it acquired, so a rolled-back or exiting listener can never remove
a subscription another listener still holds.
Direct calls to ExMCP.Client.subscribe_resource/3 and
ExMCP.Client.unsubscribe_resource/3 are not refcounted with listeners. The
server keeps one subscription per URI per session, so an explicit
unsubscribe_resource/3 also silences a listener that names the same URI.
Lifecycle
The client monitors the subscriber. When the subscriber exits, the listener
is removed and any URI no longer named by another listener is unsubscribed.
ExMCP.Client.unsubscribe_notifications/2 does the same explicitly.
When the transport closes and the client reconnects, the listener survives.
After the client has re-initialized, it re-sends resources/subscribe for
every listened URI and then tells each subscriber what happened:
{:ex_mcp_notification_reconnected, listener, %{resubscribed: uris, failed: [{uri, reason}]}}Delivery pauses between the transport loss and that message. A listener is closed, and its subscriber receives
{:ex_mcp_notification_closed, listener, reason}when the client gives up reconnecting ({:reconnect_exhausted, reason}),
when the transport closes with reconnection disabled
({:transport_closed, reason}), when the peer turns out to be modern after
a reconnect ({:era_changed, :modern}), on ExMCP.Client.disconnect/1
(:disconnected), and when the client process stops normally
({:shutdown, reason}). A client that crashes cannot send that message;
subscribers that need to notice should monitor listener.client.
Summary
Functions
Registers a listener on client. See ExMCP.Client.subscribe_notifications/3.
Removes a listener. See ExMCP.Client.unsubscribe_notifications/2.
Types
@type message() :: {:ex_mcp_notification, ExMCP.Client.NotificationListener.Ref.t(), String.t(), map()} | {:ex_mcp_notification_reconnected, ExMCP.Client.NotificationListener.Ref.t(), %{resubscribed: [String.t()], failed: [{String.t(), term()}]}} | {:ex_mcp_notification_closed, ExMCP.Client.NotificationListener.Ref.t(), term()}
Messages a subscriber receives for a listener.
@type registry() :: %{ required(reference()) => %{ ref: ExMCP.Client.NotificationListener.Ref.t(), monitor: reference() } }
Client-side registry: listener id => entry.
Functions
@spec subscribe(GenServer.server(), map(), keyword()) :: {:ok, ExMCP.Client.NotificationListener.Ref.t()} | {:error, term()}
Registers a listener on client. See ExMCP.Client.subscribe_notifications/3.
@spec unsubscribe( ExMCP.Client.NotificationListener.Ref.t(), keyword() ) :: :ok | {:error, :not_found}
Removes a listener. See ExMCP.Client.unsubscribe_notifications/2.