mcp resources/updated notification client fallback
Design an MCP server that emits `resources/updated` per spec but degrades gracefully on clients that currently ignore the notification (Claude Desktop, Claude Code) — etag-on-tool-call + lazy revalidation as a polling fallback so the same server works in both worlds.
If you read the Model Context Protocol specification looking for how a server tells a client a resource has changed, you find notifications/resources/updated quickly. The spec is clear: when a subscribed resource mutates, the server publishes the notification and the client decides whether to re-read. One round-trip becomes one push, the client's view stays fresh, the protocol does what protocols are supposed to do.
Now look at what actual MCP clients do today. Claude Desktop, the canonical MCP host, does not currently expose the notification in its UI loop. Claude Code, the developer-facing host, treats it the same way: the channel exists in the SDK, but the host application does not act on it. A lot of in-house clients written against the SDK have not wired up the notification handler at all. The spec is one world; production is another.
A server that emits notifications/resources/updated and nothing else looks correct on paper and broken in practice. Half the population of clients never finds out the resource moved. The fix is not to abandon the notification. It is to expose a second path, a tool the client calls anyway, that returns the resource's current etag. Clients that subscribe to notifications keep their existing behavior. Clients that ignore notifications poll the tool, compare etags, and re-read only when the value changes. One server, two code paths, one canonical version of the truth.
This article walks through that design as a small Python package. Each lesson lands as one git commit on a public companion repo, so you can step through the construction. We use the official MCP Python SDK for the wire layer and keep the rest of the code framework-agnostic so the tests run without launching a stdio session.
Lesson 1: a versioned tag the client can keep
Before you can tell a client a resource changed, you need something compact the client can store and hand back. An etag is exactly that: a small string that uniquely identifies one version of the content. HTTP has been using etags for this purpose since RFC 7232; MCP does not have them in the spec, so we add a local convention that we control.
The EtagStore is a thread-safe map of resource URI to a version + timestamp pair. The bump method is the only mutation: it reads the previous tuple, increments the version, stamps the current time, and stores the new tuple atomically. Read methods grab the lock briefly and return immutable dataclasses, so callers cannot accidentally mutate the stored state.
# src/mcp_etag_fallback/etag.py
@dataclass(frozen=True)
class Etag:
version: int
updated_at: float
def encode(self) -> str:
return f"v{self.version}-{int(self.updated_at)}"
class EtagStore:
def __init__(self) -> None:
self._etags: dict[str, Etag] = {}
self._lock = Lock()
def bump(self, uri: str) -> Etag:
with self._lock:
prev = self._etags.get(uri, Etag(version=0, updated_at=0.0))
nxt = Etag(version=prev.version + 1, updated_at=time.time())
self._etags[uri] = nxt
return nxt
The version is a plain monotonic counter. The timestamp lets you eyeball when a write happened during debugging but is not used for ordering: monotonic time on macOS occasionally jumps backward when the system clock syncs, so two clients comparing timestamps would disagree about who wrote first. Versions never go backward as long as a single process owns the store.
Why include both fields? Because the encoded etag becomes the cache key downstream clients use. If two writes happen in the same second, the version disambiguates; if a server restarts and resets version to zero, the timestamp keeps the encoded value unique enough that a stale client does not accidentally match a fresh tag.
Tests for this lesson check the basic invariants: unknown URIs return version zero, sequential bumps increment by one, is_stale treats a missing client etag as stale, and encoding is deterministic for equal values. Try it at commit 956d13d.
Lesson 2: a resource manager that owns the writes
Now we need a thing that holds content and bumps the etag on every write. ResourceManager is that thing. Registration is the only entry point that creates new keys; updates modify existing keys and bump; reads return immutable snapshots.
# src/mcp_etag_fallback/server.py
class ResourceManager:
def __init__(
self,
etag_store: EtagStore | None = None,
publisher: NotificationPublisher | None = None,
) -> None:
self._etags = etag_store or EtagStore()
self._publisher = publisher
self._content: dict[str, str] = {}
self._lock = Lock()
def update(self, uri: str, content: str) -> ResourceSnapshot:
with self._lock:
if uri not in self._content:
raise KeyError(uri)
self._content[uri] = content
etag = self._etags.bump(uri)
snap = ResourceSnapshot(uri=uri, content=content, etag=etag)
if self._publisher is not None:
self._publisher.publish(
ResourceUpdatedNotification(uri=uri, etag=etag)
)
return snap
Two design decisions worth pausing on. First, register and update are deliberately separate methods. A register on an existing URI would erase history; raising KeyError on update of an unknown URI is the safer default. Clients that want upsert semantics can compose try-register-then-fall-back-to-update themselves, but the manager itself stays strict.
Second, the notification publish happens outside the lock. If a subscriber were slow, holding the lock would block every other reader for the duration. We capture the snapshot inside the lock, release it, then call publish. The publisher itself is thread-safe so concurrent updates are still safe; they may just deliver in a different order than they bumped, which is fine because the etag carried in the notification is the source of truth, not the delivery order.
Tests in lesson 2 cover register-then-read, multiple updates, key-error on unknown URI, and listing. All run without the MCP SDK because the manager talks to no transport at this level: it is pure Python state. Try it at commit 6e45dce.
Lesson 3: emit the spec notification
NotificationPublisher is the boring part. Subscribers register callbacks, publishes fan out to every subscriber, that is it. We keep it synchronous on purpose: a misbehaving subscriber should block its peers loudly rather than fail silently, because that is how you find the bug.
# src/mcp_etag_fallback/notifications.py
@dataclass(frozen=True)
class ResourceUpdatedNotification:
uri: str
etag: Etag
class NotificationPublisher:
def __init__(self) -> None:
self._subscribers: list[Subscriber] = []
self._lock = Lock()
def publish(self, note: ResourceUpdatedNotification) -> int:
with self._lock:
subs = list(self._subscribers)
for sub in subs:
sub(note)
return len(subs)
We attach the etag to the notification even though the MCP spec only requires uri. The motivation is symmetry: a notification-aware client now has the same information the polling client gets back from the tool, so the two code paths converge on the same client-side state. A client could legitimately ignore the etag field, but having it there means we never need a follow-up read just to learn the version.
Why a register of a new resource produces no notification matters here. The spec defines notifications/resources/list_changed for the case "the set of resources expanded or contracted" and notifications/resources/updated for "a resource you already know about mutated". Conflating the two would force clients to re-fetch list on every register, which gets expensive for servers that register many resources at startup. Lesson 3's tests assert that register stays silent and only update publishes.
The next moment is the interesting one. Our publisher emits to whatever subscribers register. The official MCP Python SDK exposes a session helper that translates a Python notification into the wire message. In the real transport wiring (lesson 5), we register a subscriber that calls session.send_resource_updated(uri). Right now we only have in-process subscribers and tests; the wire layer is deferred so each lesson stays focused.
Read the source at commit a1ef67e. The Model Context Protocol's notification spec lives at modelcontextprotocol.io/specification if you want the exact JSON-RPC envelope.
Lesson 4: the tool the polling client actually calls
Here is the second half of the design. A client that ignores notifications still calls tools, because tools are the primary way an LLM produces side-effects in MCP. If we expose a get_resource_etag tool, every client that talks to our server gets access to the same versioning information, regardless of whether it subscribes to anything.
# src/mcp_etag_fallback/tools.py
class EtagTool:
NAME = "get_resource_etag"
INPUT_SCHEMA: dict[str, object] = {
"type": "object",
"properties": {
"uri": {"type": "string"},
"client_etag": {"type": ["string", "null"]},
},
"required": ["uri"],
}
def invoke(self, uri: str, client_etag: str | None = None) -> ToolResult:
snap = self._rm.read(uri)
encoded = snap.etag.encode()
stale = encoded != client_etag if client_etag else True
return ToolResult(
uri=uri,
etag_encoded=encoded,
version=snap.etag.version,
stale_for_client=stale,
)
The tool's input takes the URI plus the client's last-known etag. The output says "here is my current etag, and stale_for_client is true if you should re-read". That last bit looks redundant given the client could compare the encoded string itself, but advertising the decision in the response lets the model that consumes the tool output skip the comparison: it sees a boolean and knows what to do.
A bit of cost analysis matters here. If a poll-only client ticks every 5 seconds and the resource changes once an hour, roughly 99% of poll calls return stale_for_client=False. Without the etag check, that same client would call resources/read every tick, transferring the full resource body even when nothing changed. For a 4 KB resource that is roughly 80% bandwidth savings over the same period. The etag call still costs one round-trip and a 32-byte response, but it never carries the resource body unless something actually changed.
This is the trade-off the article is built around. Push notifications get you sub-100ms reactivity at the price of needing a client that listens. Polling with an etag check gets you the same correctness with predictable cost and works on any client that calls tools. The same server handles both because the etag store and the resource manager are shared underneath. Choose push when you need immediate UI reflection of model changes; choose poll when your client does not process notifications or when you are crossing a network boundary that drops them.
Commit cb89bfd contains the tool plus its tests.
Lesson 5: same server, two clients, one source of truth
The last commit wires everything together. demos.py contains two functions: run_push_demo and run_poll_demo. Both act on the same ResourceManager shape. The push demo subscribes to the publisher and reacts on each notification; the poll demo creates an EtagTool and ticks through a sequence of polls and updates.
# src/mcp_etag_fallback/demos.py
def run_push_demo() -> list[str]:
pub = NotificationPublisher()
rm = ResourceManager(publisher=pub)
rm.register("notes://main", "first body")
log: list[str] = []
def on_update(note):
snap = rm.read(note.uri)
log.append(f"push: {note.uri} v{note.etag.version} -> {snap.content!r}")
pub.subscribe(on_update)
rm.update("notes://main", "second body")
rm.update("notes://main", "third body")
return log
mcp_transport.py is the real wire adapter. It imports mcp.server.Server lazily inside build_server, so the rest of the codebase runs without the SDK installed. The adapter registers list_resources, read_resource, list_tools, call_tool, and then attaches one more subscriber to the publisher that forwards each notification into the MCP session via send_resource_updated. A client that subscribed will get the wire notification; a client that ignored will still see the result via the tool.
The end-to-end test is the proof. The push demo log has two entries (v2, v3) because the subscriber registered after the initial register. The poll demo log has three entries (v1, v2, v3) because the first poll has no prior etag and treats the v1 state as stale. Both demos walk away with the same final version of the resource, observed through different mechanisms. Tests for this integration live in tests/test_end_to_end.py.
A quick word about real deployment: when you run examples/push_client.py you see two log lines; examples/poll_client.py produces three. If you wanted to ship this server to a client like Claude Code today, the relevant piece is that the etag tool gets called every time the model decides to check for changes. The MCP Python SDK that handles the actual transport lives at github.com/modelcontextprotocol/python-sdk.
Read the final state at commit f117482.
Repository
Full source at https://github.com/vytharion/mcp-resources-updated-notification-client-fallback.
- Lesson 1 -> 956d13d - EtagStore + thread-safe version bumps
- Lesson 2 -> 6e45dce - ResourceManager with versioned read/update
- Lesson 3 -> a1ef67e - NotificationPublisher + spec-aligned resources/updated
- Lesson 4 -> cb89bfd - get_resource_etag tool as fallback for poll-only clients
- Lesson 5 -> f117482 - end-to-end demos + MCP SDK transport adapter
Clone the repo, run uv sync, then uv run pytest to walk through the 21 tests. The example clients run with uv run python examples/push_client.py and uv run python examples/poll_client.py.
What to do next
The pattern this article ships is not novel: HTTP figured it out a decade ago with etags and conditional requests. What is new is the gap between the MCP spec and current client behavior, which means a server that follows the spec literally still fails for half its users. Building the etag tool is roughly an afternoon of work and survives both worlds.
If you want to extend the example: swap the in-memory content for a file watcher and bump the etag from the os.stat mtime, or add a client_etag field to your existing tools so the model can short-circuit when nothing has moved. The publisher pattern also generalizes nicely; subscribers can write to a metrics counter, a debug log, or a second transport, all from the same publish call.
The most useful test you can add to your own MCP server is one that asserts the etag tool returns stale_for_client=False after a no-op time tick. That single test catches every bug where the etag accidentally bumps when nothing changed, which is the failure mode that quietly destroys cache hit rates in production.