MCP Resource Subscriptions and Live Updates
MCP resources give an agent read access to data a server controls: a file, a database row, a log tail, a ticket. Without subscriptions, the only way to know if that data changed is to re-read it on a timer. MCP resource subscriptions solve this by letting a client tell a server "notify me when this resource changes," so the server pushes a notifications/resources/updated message the moment the underlying data moves. This article walks through how the subscription lifecycle works, how to implement it on both the server and client side, and where it breaks down in practice.
What MCP resources are, briefly
A resource in the Model Context Protocol is anything a server exposes with a URI: file:///var/log/app.log, postgres://orders/12345, git://repo/HEAD, or a custom scheme like ticket://JIRA-4821. The server advertises resources through resources/list, and a client reads one with resources/read, which returns the current content (text or binary, base64-encoded).
That's the static picture. Most interesting resources aren't static. A log file grows. A ticket status flips from "open" to "closed." A dataset gets a new row. An agent that only reads once at the start of a conversation is working from a stale snapshot, and re-reading every resource on every turn is wasteful and slow, especially for large resources or ones behind a slow backend call.
Resource subscriptions exist to close that gap without polling.
The subscription lifecycle
MCP defines two related but distinct notification types, and mixing them up is the single most common mistake when implementing this:
notifications/resources/list_changed: the *set* of available resources changed. A new file appeared, an old one got deleted, a resource was renamed. The client should callresources/listagain to get the new list.notifications/resources/updated: the *content* of a specific, already-known resource changed. The client should callresources/readon that URI to get the fresh content. This one only fires for resources the client explicitly subscribed to.
A server declares subscription support in its capabilities during initialization:
{
"capabilities": {
"resources": {
"subscribe": true,
"listChanged": true
}
}
}subscribe: true means the server accepts resources/subscribe requests for individual URIs. listChanged: true means the server will proactively tell clients when the resource catalog itself changes, independent of any subscription. A server can support one, both, or neither.
The subscribe/unsubscribe request pair
Once a client has read a resource once and wants to stay current on it, it sends:
{
"jsonrpc": "2.0",
"id": 7,
"method": "resources/subscribe",
"params": {
"uri": "file:///var/log/app.log"
}
}The server responds with an empty result on success. From that point forward, whenever the resource's content changes, the server sends an unsolicited notification (no id, since notifications don't get replies):
{
"jsonrpc": "2.0",
"method": "notifications/resources/updated",
"params": {
"uri": "file:///var/log/app.log"
}
}Note what's *not* in that payload: the new content. The notification is a pointer, not a payload. The client still has to call resources/read to fetch the current state. This is deliberate: it keeps the notification cheap to send even if the resource is large, and it lets the client decide when it actually wants to pay the cost of reading (an agent mid-turn might defer the read until it's about to use that resource again).
To stop getting notified:
{
"jsonrpc": "2.0",
"id": 8,
"method": "resources/unsubscribe",
"params": {
"uri": "file:///var/log/app.log"
}
}Servers should also treat a client disconnect as an implicit unsubscribe from everything that client was watching. If you're building a server, don't leak subscription state across sessions.
Implementing subscriptions on a server (Python SDK)
Here's a minimal server that watches a directory and pushes updates when files inside it change. This uses the official Python MCP SDK's low-level server, since subscription handling isn't fully wrapped by the high-level FastMCP decorators in every SDK version, so it's worth seeing the raw mechanics.
import asyncio
from pathlib import Path
from mcp.server import Server
from mcp.server.models import InitializationOptions
import mcp.server.stdio
import mcp.types as types
WATCH_DIR = Path("/tmp/watched")
server = Server("resource-subscriber-demo")
subscriptions: set[str] = set()
known_mtimes: dict[str, float] = {}
@server.list_resources()
async def list_resources() -> list[types.Resource]:
resources = []
for f in WATCH_DIR.glob("*.txt"):
resources.append(
types.Resource(
uri=f"file://{f}",
name=f.name,
mimeType="text/plain",
)
)
return resources
@server.read_resource()
async def read_resource(uri: str) -> str:
path = Path(uri.replace("file://", ""))
return path.read_text()
@server.subscribe_resource()
async def subscribe_resource(uri: str) -> None:
subscriptions.add(uri)
path = Path(uri.replace("file://", ""))
if path.exists():
known_mtimes[uri] = path.stat().st_mtime
@server.unsubscribe_resource()
async def unsubscribe_resource(uri: str) -> None:
subscriptions.discard(uri)
known_mtimes.pop(uri, None)
async def poll_for_changes():
while True:
await asyncio.sleep(2)
for uri in list(subscriptions):
path = Path(uri.replace("file://", ""))
if not path.exists():
continue
mtime = path.stat().st_mtime
if known_mtimes.get(uri) != mtime:
known_mtimes[uri] = mtime
await server.request_context.session.send_resource_updated(uri)
async def main():
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
poll_task = asyncio.create_task(poll_for_changes())
try:
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name="resource-subscriber-demo",
server_version="0.1.0",
capabilities=server.get_capabilities(
notification_options=None,
experimental_capabilities={},
),
),
)
finally:
poll_task.cancel()
if __name__ == "__main__":
asyncio.run(main())Two things worth flagging about this example. First, it polls the filesystem internally, then translates that into MCP notifications. This is normal: MCP itself doesn't specify *how* a server detects change, only how it reports it. Real servers back this with database triggers, webhooks, inotify/fsevents, or a change-data-capture stream, whichever fits the underlying system. Second, known_mtimes is per-process state, not per-client. In a multi-client server you'd track subscriptions per session so client A doesn't get flooded with updates for a resource only client B asked about.
Implementing subscriptions on a server (TypeScript SDK)
The TypeScript SDK exposes the same shape through server.resource() request handlers and server.notification():
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
SubscribeRequestSchema,
UnsubscribeRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { watch } from "node:fs";
const server = new Server(
{ name: "resource-subscriber-demo", version: "0.1.0" },
{ capabilities: { resources: { subscribe: true } } }
);
const subscribedUris = new Set<string>();
server.setRequestHandler(SubscribeRequestSchema, async (request) => {
const { uri } = request.params;
subscribedUris.add(uri);
return {};
});
server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
subscribedUris.delete(request.params.uri);
return {};
});
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const path = request.params.uri.replace("file://", "");
const text = await Bun.file(path).text();
return { contents: [{ uri: request.params.uri, mimeType: "text/plain", text }] };
});
function watchAndNotify(dir: string) {
watch(dir, (_event, filename) => {
if (!filename) return;
const uri = `file://${dir}/${filename}`;
if (subscribedUris.has(uri)) {
server.notification({
method: "notifications/resources/updated",
params: { uri },
});
}
});
}
watchAndNotify("/tmp/watched");
const transport = new StdioServerTransport();
await server.connect(transport);This version uses fs.watch directly instead of polling, which is closer to what a production server should do when the underlying store supports native change events. Prefer this whenever the platform gives you one: database LISTEN/NOTIFY in Postgres, a Redis keyspace notification, a webhook from a SaaS API. Polling loops are a fallback, not a default.
Handling updates on the client side
On the client, subscribing is only half the job. You need a notification handler that reacts to notifications/resources/updated by deciding whether to re-read immediately or lazily. For an agent host, immediate re-reads matter for resources actively in context; for anything else, deferring until the resource is next referenced saves tokens and round trips.
from mcp import ClientSession
async def run_client(session: ClientSession):
await session.initialize()
def on_resource_updated(params):
uri = params.uri
print(f"resource changed: {uri}, scheduling re-read")
pending_refresh.add(uri)
session.set_notification_handler(
"notifications/resources/updated", on_resource_updated
)
await session.subscribe_resource("file:///tmp/watched/status.txt")
# later, when the agent actually needs the content again:
if "file:///tmp/watched/status.txt" in pending_refresh:
result = await session.read_resource("file:///tmp/watched/status.txt")
pending_refresh.discard("file:///tmp/watched/status.txt")The pending_refresh set pattern is worth calling out because it's the practical middle ground between two bad extremes: eagerly re-reading on every notification (burns tokens and API calls for resources the agent won't look at again this turn) and ignoring notifications entirely (defeats the point of subscribing). Mark stale, refresh on next use.
Where this actually pays off
Subscriptions aren't free complexity for their own sake. They matter most in a few concrete shapes of workload:
Long-running agent sessions watching external state. An agent monitoring a CI pipeline, a deployment rollout, or a support ticket queue benefits enormously from push notifications instead of a while True: read; sleep(30) loop baked into the tool description. The agent can go do other work and get interrupted only when something worth reacting to happens.
Multi-agent or multi-client setups sharing one server. If two agent sessions are both looking at the same dataset resource, subscriptions let the server fan out one change event to both, rather than each client independently re-polling and doubling load on whatever backs the resource.
Expensive resources. If resources/read triggers a slow query, a large file scan, or a paid API call, polling is directly wasteful. Subscribing turns "check every N seconds" into "check exactly when it matters."
Resources with bursty, unpredictable change patterns. Log tails and event streams change at irregular intervals. A fixed polling interval either misses bursts (too slow) or wastes calls during quiet periods (too fast). Push notifications track the actual rate of change.
Where it falls short
Subscriptions add real operational weight, and it's worth being honest about the tradeoffs before wiring them into every resource a server exposes.
Not every transport handles server-initiated messages equally well. Stdio transports are fine, since the connection is a persistent process pipe. HTTP-based transports need Server-Sent Events or a long-lived streaming connection to carry unsolicited notifications back to the client; a plain request/response HTTP integration can't receive a push at all, so subscriptions silently do nothing useful there unless the transport supports streaming.
Subscription state is easy to leak. If a server doesn't clean up on disconnect, or a client subscribes and forgets to unsubscribe when it's done with a resource, you end up with change-detection machinery (file watchers, DB listeners, polling loops) running forever for nobody. Tie subscription lifetime to session lifetime explicitly, don't assume garbage collection will handle it.
Not every server needs this at all. If a resource genuinely never changes mid-session, like a static config file the server itself doesn't modify, subscribing is pure overhead. Reserve subscribe: true for resources that actually mutate while a client might be holding a reference to them.
Finally, subscriptions tell you *that* something changed, not *what* changed. For resources where the delta matters (a log growing by three lines vs. being truncated and rewritten), the client still has to diff old and new content itself after re-reading. MCP doesn't ship a diff protocol; it ships a doorbell.
A practical checklist for adding subscriptions to your server
- Declare
resources: { subscribe: true }in server capabilities only for resource types that actually mutate. - Track subscriptions per client session, not globally, so notifications don't cross sessions.
- Prefer native change detection (filesystem watchers, DB triggers, webhooks) over polling loops; if you must poll, keep the interval configurable.
- Clean up subscriptions and any backing watchers on client disconnect.
- Keep
notifications/resources/updatedpayloads to just the URI; don't smuggle content into the notification, and don't assume the client will re-read immediately. - If you're behind an HTTP transport, confirm your streaming layer (SSE or equivalent) actually delivers server-initiated messages before you build subscription logic on top of it.
- Log subscribe/unsubscribe events during development; silent subscription leaks are hard to spot otherwise.
FAQ
What's the difference between `resources/subscribe` and polling `resources/list` on a timer? resources/subscribe asks the server to push a notification when one specific resource's content changes, so the client does zero work until something actually happens. Polling resources/list on a timer only tells you if the catalog of available resources changed (new or removed items), not whether an existing resource's content changed, and it burns a request every interval whether or not anything moved.
Does the update notification include the new content? No. notifications/resources/updated carries only the URI. The client must call resources/read again to fetch the current content. This keeps notifications lightweight even for large resources.
Can a server support `list_changed` without supporting `subscribe`? Yes, they're independent capabilities. A server can announce that its resource catalog changes (files added/removed) without offering per-resource content-change subscriptions, and vice versa.
What happens to subscriptions if the connection drops? Subscriptions are tied to the session. A dropped connection should be treated by the server as an implicit unsubscribe from everything that client was watching; a reconnecting client needs to resubscribe explicitly rather than assuming state survived.
Do subscriptions work over HTTP transports, or only stdio? They work over any transport that supports server-initiated (unsolicited) messages. Stdio handles this natively. HTTP-based MCP transports need a streaming mechanism, typically Server-Sent Events, to carry notifications back to the client; a request/response-only HTTP setup cannot deliver them.
How many resources should an agent subscribe to at once? As few as it actually needs live data for. Subscribing broadly "just in case" turns into subscription sprawl on the server and a flood of notifications the agent has to triage. Subscribe to what's actively in play for the current task and unsubscribe when that task wraps up.
Is there a standard way to detect content changes on the server side? No, MCP leaves that entirely to the server implementation. Use whatever native change-detection mechanism the underlying system offers (filesystem events, database triggers, message queues, webhooks) and translate it into notifications/resources/updated calls; fall back to polling only when nothing better is available.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.