# `ExMCP.Client.NotificationListener`
[🔗](https://github.com/azmaveth/ex_mcp/blob/v1.5.0/lib/ex_mcp/client/notification_listener.ex#L1)

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

# `message`

```elixir
@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`

```elixir
@type registry() :: %{
  required(reference()) =&gt; %{
    ref: ExMCP.Client.NotificationListener.Ref.t(),
    monitor: reference()
  }
}
```

Client-side registry: listener id => entry.

# `subscribe`

```elixir
@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`

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

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
