subscriptions/listen: change notifications in MCP

subscriptions/listen: change notifications in MCP

In the 2026-07-28 Model Context Protocol (MCP) specification, every request but one is stateless and can be served by any server instance. subscriptions/listen is the deliberate exception: it opens a stream that carries change notifications until it’s cancelled. I like how much this one method replaces: resources/subscribe, resources/unsubscribe and the old HTTP GET endpoint.

This is part 17 of my series on what an MCP server does under the 2026-07-28 specification, after progress, cancellation and timeouts in part 16. The facts are as I read them in October 2026.

In brief

  1. subscriptions/listen is the one deliberately long-lived request in an otherwise stateless protocol. It holds a stream open for change notifications, and its response only arrives when the stream closes cleanly.
  2. The server acknowledges first and sends only what was asked for. The acknowledgement echoes the types it will honour, and no notification on that subscription may come before it.
  3. Notifications are signals, not payloads. Each carries the subscription’s ID, and the client reads or lists again.
  4. Nothing replays missed events. After a reconnect, a client subscribes again and re-fetches.
  5. My advice: plan for fan-out. A stream is pinned to one instance, so every instance has to hear about every change.

Opening a stream

The client sends subscriptions/listen, a JSON-RPC 2.0 request, with a notifications filter listing the event types it wants. The specification’s Subscriptions page says the server MUST NOT send any type the client didn’t explicitly request.

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "subscriptions/listen",
  "params": {
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {}
    },
    "notifications": {
      "toolsListChanged": true,
      "resourceSubscriptions": ["file:///project/config.json"]
    }
  }
}
Filter fieldDelivers
toolsListChanged: truenotifications/tools/list_changed
promptsListChanged: truenotifications/prompts/list_changed
resourcesListChanged: truenotifications/resources/list_changed
resourceSubscriptions: [uris]notifications/resources/updated for those Uniform Resource Identifiers (URIs)
taskIds: [ids] (Tasks extension)notifications/tasks with the full task state

Every field is optional; leaving one out means not subscribing to that type.

Acknowledgement first

The server MUST send notifications/subscriptions/acknowledged as the first message on the subscription, and MUST NOT send any notification before it. It echoes only the subset the server will honour, and clients SHOULD compare it with what they asked for.

Every message is tagged

Every message on the stream carries io.modelcontextprotocol/subscriptionId in _meta. Its value is the JSON-RPC id of the listen request, 1 in the example above:

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

Notifications are signals, not payloads. On resources/updated the client calls resources/read (part 10 covers resources), and on a list_changed it calls the matching */list again.

Client → Server: subscriptions/listen (id 1) toolsListChanged, resourceSubscriptions [config.json]; Server → Client: notifications/subscriptions/acknowledged subscriptionId 1, honoured filter; Note (Server): config.json changes; Server → Client: notifications/resources/updated (subscriptionId 1); Client → Server: resources/read config.json (separate request); Server → Client: new contents; Note (Server): A tool is added; Server → Client: notifications/tools/list_changed (subscriptionId 1); Client → Server: tools/list (separate request); Server → Client: updated tools; Note (Server): Server shutting down; Server → Client: response to request 1, resultType complete (graceful close)ServerClientconfig.json changesA tool is addedServer shutting downsubscriptions/listen (id 1)toolsListChanged, resourceSubscriptions [config.json]notifications/subscriptions/acknowledgedsubscriptionId 1, honoured filternotifications/resources/updated (subscriptionId 1)resources/read config.json (separate request)new contentsnotifications/tools/list_changed (subscriptionId 1)tools/list (separate request)updated toolsresponse to request 1, resultType complete (graceful close)

Several streams, endings and reconnects

A client MAY hold several subscriptions at once, for example one for tool-list changes and one per group of resources. On stdio they share the one channel, so clients MUST use subscriptionId to tell each subscription’s messages apart (part 7 covers the transports). The acknowledgement-first rule is per subscription, so another subscription’s messages may arrive before a given acknowledgement.

Ending a subscription

Who ends itHow
The clientCloses the Server-Sent Events (SSE) stream on HTTP, or sends notifications/cancelled with the listen request’s ID on stdio
The server, for example at shutdownSHOULD respond to the original listen request with a completion result (resultType: "complete" plus the subscription ID), then close the stream
The transportAn HTTP timeout, a dropped connection or the stdio process exiting, with no response

A graceful response says the subscription closed cleanly; a stream that ends without one is an unexpected disconnect, which the client MAY treat as a cue to reconnect. The cancellation page also says a server MUST send notifications/cancelled for the listen request when it tears a subscription down. My advice for clients: treat either signal as a clean close.

Reconnection and missed events

  • On stdio, the server holds no subscription state across reconnects, so the client MUST send subscriptions/listen again.
  • On HTTP, SSE resumability is gone (part 2), so nothing replays missed events. After any reconnect, subscribe again and re-fetch whatever you cache: assume something changed during the gap.
  • For clients that don’t subscribe at all, the ttlMs hints from part 6 are the safety net.

Clients reopen closed streams within limits. As of October 2026, Claude Code sets two limits. If a stream closes again within 10 seconds, Claude Code reopens it up to three times, then stops for that connection. If it stayed open longer before closing, as streams to serverless hosts commonly do, Claude Code waits about six hours after five reopens in an hour, keeping the server’s last fetched lists until then.

Notifying a change from Python

With the official Python software development kit (SDK), mcp 2.3.0, MCPServer handles the acknowledgement, the tags and the filtering for you. Your part is to say what changed. Here a tool changes a resource and says so:

from mcp.server.mcpserver import Context, MCPServer

mcp = MCPServer("acme-ops")
CONFIG = {"region": "eu-west"}


@mcp.resource("config://app")
def app_config() -> str:
    return f"region={CONFIG['region']}"


@mcp.tool()
async def set_region(region: str, ctx: Context) -> str:
    """Move the app to another region."""
    CONFIG["region"] = region
    await ctx.notify_resource_updated("config://app")
    return f"region set to {region}"

ctx.notify_tools_changed() does the same for the tool list. The in-memory client opens a stream, makes the change and reads again after the signal:

import anyio
from mcp import Client


async def main():
    async with Client(mcp) as client:
        async with client.listen(resource_subscriptions=["config://app"]) as sub:
            honored = sub.honored.model_dump(by_alias=True, exclude_none=True)
            print(sub.subscription_id, honored)
            await client.call_tool("set_region", {"region": "us-east"})
            event = await anext(sub)
            contents = (await client.read_resource(event.uri)).contents
            print(event, contents[0].text)


anyio.run(main)

It prints:

listen-1 {'resourceSubscriptions': ['config://app']}
ResourceUpdated(uri='config://app') region=us-east

What the SDK does here:

  • Entering client.listen(...) waits for the acknowledgement, and sub.honored is the acknowledged filter. The client’s string ID, listen-1, is the subscriptionId on every message.
  • MCPServer honours every requested type and URI, even a URI that doesn’t exist (nothing ever arrives for it). The SDK’s subscriptions guide shows a middleware that refuses URIs the caller may not read.
  • A graceful close ends iteration over sub, so an async for loop finishes; an abrupt drop raises SubscriptionLost, your cue to listen again. In my test, the SDK also treated a server’s notifications/cancelled as a drop, where my advice above would close cleanly.

One stream, many instances

Unlike other requests, a listen stream is pinned to the instance that accepted it, and that instance has to hear about changes made elsewhere, here through a publish/subscribe (pub/sub) bus:

Change sources: Feature-flag service tool enabled, Database write resource updated, Deployment new prompt version; MCP server instances: Instance A holds streams 1, 4, Instance B holds stream 2, Instance C holds stream 3; Feature-flag service tool enabled → Pub/sub bus Redis, Service Bus, Event Grid; Database write resource updated → Pub/sub bus Redis, Service Bus, Event Grid; Deployment new prompt version → Pub/sub bus Redis, Service Bus, Event Grid; Pub/sub bus Redis, Service Bus, Event Grid → Instance A holds streams 1, 4; Pub/sub bus Redis, Service Bus, Event Grid → Instance B holds stream 2; Pub/sub bus Redis, Service Bus, Event Grid → Instance C holds stream 3; Instance A holds streams 1, 4 → Client 1: filter by acknowledged set; Instance A holds streams 1, 4 → Client 4; Instance B holds stream 2 → Client 2; Instance C holds stream 3 → Client 3Change sourcesMCP server instancesFeature-flag servicetool enabledDatabase writeresource updatedDeploymentnew prompt versionPub/sub busRedis, Service Bus, Event GridInstance Aholds streams 1, 4Instance Bholds stream 2Instance Cholds stream 3Client 1Client 2Client 3Client 4filter by acknowledged set

In the Python SDK, each instance reaches that bus through a SubscriptionBus, passed as MCPServer(subscriptions=...). The default, InMemorySubscriptionBus, reaches only its own process: in my test, a change on one instance left a stream on another silent. Your own SubscriptionBus needs two methods, publish and subscribe, and carries typed events, not JSON-RPC, so the SDK still tags and filters:

from collections.abc import Callable

from mcp.server.mcpserver import MCPServer
from mcp.server.subscriptions import InMemorySubscriptionBus, ServerEvent


class BrokerBus:
    """One instance's SubscriptionBus, over a pub/sub broker all instances share."""

    def __init__(self, broker) -> None:
        self._broker = broker
        self._local = InMemorySubscriptionBus()  # the streams this instance holds
        broker.on_event(self._local.publish)  # events published by any instance

    async def publish(self, event: ServerEvent) -> None:
        await self._broker.publish(event)

    def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
        return self._local.subscribe(listener)


def build_instance(broker) -> MCPServer:
    return MCPServer("acme-ops", subscriptions=BrokerBus(broker))

broker stands for your pub/sub client; with a real one you also encode each event and run, on every instance, a reader task that hands arriving events to the local bus. Against an in-process stand-in, a change on one instance reached a stream on another.

My checklist for servers

The rules above are the spec’s; what follows is my own advice.

  • If you run the stream yourself, without an SDK, register the subscription, send the acknowledgement, and only then start delivering, or you’ll break the ordering rule under concurrency. Filter outgoing notifications against the acknowledged set, not the requested set.
  • With ping gone, proxies with idle timeouts will cut quiet streams: size the timeouts for it, and expect clients to reconnect and list again.
  • Decide what happens when a bearer token expires mid-stream. A common policy closes the stream at expiry, so the client reconnects with a fresh token.

Method and caveats

  • Built from my guide’s chapter on subscriptions, written against the 2026-07-28 specification. Claude Code’s behaviour is as its documentation described it on 8 October 2026.
  • Both Python samples ran in memory against mcp 2.3.0 on 9 October 2026; the output shown is the first sample’s. The other SDK notes come from the package’s source and short probes.
  • Where Claude Code’s documentation is more precise than my notes (after three quick reopens it stops for that connection), this article follows the documentation.