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
subscriptions/listenis 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.- 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.
- Notifications are signals, not payloads. Each carries the subscription’s ID, and the client reads or lists again.
- Nothing replays missed events. After a reconnect, a client subscribes again and re-fetches.
- 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/listenagain. - 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
ttlMshints 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, andsub.honoredis the acknowledged filter. The client’s string ID,listen-1, is thesubscriptionIdon every message. MCPServerhonours 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 anasync forloop finishes; an abrupt drop raisesSubscriptionLost, your cue to listen again. In my test, the SDK also treated a server’snotifications/cancelledas 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
pinggone, 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
mcp2.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.