ExMCP.Client.NotificationListener (ex_mcp v1.5.0)

Copy Markdown View Source

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 requestsubscriptions/listen, acknowledged by the serverNone for list changes; resources/subscribe per URI
CorrelationServer-supplied subscription id on every eventNone; the client matches on method and URI
ReconnectRe-opened by the subscription process with a resync snapshotResource URIs re-subscribed; no snapshot
Task eventstaskIds filterNot available
OwnerAn ExMCP.Client.Subscription processState 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 matching list_changed notification;
  • "resourceSubscriptions" (a list of URIs) enables notifications/resources/updated for 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

Types

Messages a subscriber receives for a listener.

Client-side registry: listener id => entry.

Types

message()

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

registry()

@type registry() :: %{
  required(reference()) => %{
    ref: ExMCP.Client.NotificationListener.Ref.t(),
    monitor: reference()
  }
}

Client-side registry: listener id => entry.

Functions

subscribe(client, filter, opts \\ [])

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

unsubscribe(ref, opts \\ [])

@spec unsubscribe(
  ExMCP.Client.NotificationListener.Ref.t(),
  keyword()
) :: :ok | {:error, :not_found}

Removes a listener. See ExMCP.Client.unsubscribe_notifications/2.