cd /news/agent-protocols/subscriptions-listen-change-notifica… · home › topics › agent-protocols › article
[ARTICLE · art-154118] src=pournasserian.com ↗ pub= topic=agent-protocols verified=true sentiment=· neutral

subscriptions/listen: change notifications in MCP

The 2026-07-28 Model Context Protocol (MCP) specification makes `subscriptions/listen` the protocol's one deliberately long-lived request in an otherwise stateless design, replacing `resources/subscribe`, `resources/unsubscribe` and the old HTTP GET endpoint with a single stream that carries change notifications until cancelled. Under the spec, a server must send `notifications/subscriptions/acknowledged` as the first message, must not send any notification type the client did not explicitly request, and must tag every message with `io.modelcontextprotocol/subscriptionId`; notifications are signals only, so clients re-read or re-list, and nothing replays missed events after a reconnect. The author, writing part 17 of a series on MCP server behavior, advises developers to plan for fan-out because a stream is pinned to one instance, so every instance must hear about every change.

by read7 min views1 publishedOct 20, 2026
subscriptions/listen: change notifications in MCP
Image: Pournasserian (auto-discovered)

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 field Delivers
toolsListChanged: true notifications/tools/list_changed
promptsListChanged: true notifications/prompts/list_changed
resourcesListChanged: true notifications/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.

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 it How
The client Closes 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 shutdown SHOULD respond to the original listen request with a completion result ( resultType: "complete" plus the subscription ID), then close the stream
The transport An 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 frompart 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, andsub.honored is the acknowledged filter. The client’s string ID,listen-1 , is thesubscriptionId on every message.
  • MCPServer honours every requested type and URI, even a URI that doesn’t exist (nothing ever arrives for it). The SDK’ssubscriptions guide shows a middleware that refuses URIs the caller may not read.
  • A graceful close ends iteration over sub , so anasync for loop finishes; an abrupt drop raisesSubscriptionLost , your cue to listen again. In my test, the SDK also treated a server’snotifications/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:

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.
── more in #agent-protocols 4 stories · sorted by recency
── more on @model context protocol 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/subscriptions-listen…] indexed:0 read:7min 2026-10-20 · —