Chapter 5 of 6 · 14 min read
Resource subscriptions and staleness
On this page
Everything so far has treated a read as a point event: the host resolves a URI, the server
answers, the bytes go into the context. That is accurate and it is only half the life of an
attached document, because the context window a turn is composed from persists, and the
document behind the URI does not stop changing while it sits there. A configuration value at
config://service/limits, a sprint board, a ticket status — each was true when it was read
and each has its own rate of going wrong afterwards.
MCP has one mechanism for this and it is narrower than it first appears. A server may declare that clients can subscribe to a URI, and thereafter tell them when the document behind it changes. That covers the transport. What it does not cover, and what most implementations stop short of, is the step from a client holding fresh bytes to a model reasoning over them — and in a session that has already spent forty turns quoting the old value, that step is the one that decides whether the agent behaves.
The subscribe flag in the capability object
A server states its position on this at initialization, and the statement is short enough to miss:
{
"capabilities": {
"resources": { "listChanged": true }
}
}No subscribe. A client reading that knows, at connection time and before a single read,
that this server will never tell it when a document changed. Capability negotiation exists so
that a client can choose a strategy with its eyes open, and a client that treats "the server
did not offer subscribe" as no information has thrown away the one signal that would have
told it to poll. Any staleness incident against such a server is the client's, and the moment
it should have been caught is the handshake rather than hour twelve.
The server is not blameless for a different reason. A server that publishes a document which
changes without warning during incidents, and gives clients no way to find out, pushes the
entire cost of freshness onto every consumer independently. Declaring subscribe: true is a
modest amount of work for a config service that already has a change hook, and the absence of
it is a design decision that should have been argued rather than defaulted into.
Whose problem freshness is therefore has an answer, and the answer is that the handshake splits it. The server owns one decision and states it once: whether it will report changes at all. Everything after that belongs to the client, because the client is the party that holds the declaration and can act on it — subscribing where the flag is present, polling where it is absent, and labelling the attachment with an age in either case. That is what capability negotiation is for, and it is why "we did not know the document had changed" is not an explanation on either side of the wire. A server that declared nothing chose to declare nothing, and a client that read the declaration and drew no conclusion from it chose that too.
What subscribe actually gives you
The mechanism is three messages. The client asks to be told about one URI, the server acknowledges, and thereafter the server sends a notification whenever the underlying document changes.
{ "jsonrpc": "2.0", "id": 12, "method": "resources/subscribe",
"params": { "uri": "config://service/limits" } }
{ "jsonrpc": "2.0", "method": "notifications/resources/updated",
"params": { "uri": "config://service/limits" } }The notification carries the URI and no contents. That is deliberate — a change notification
that shipped the new document would push bytes at every subscriber whether or not they still
care, and would have to guess at what "the new document" means for a URI that resolves
differently per session — so the client re-reads. resources/unsubscribe ends it, and a
disconnect ends it implicitly, which means subscriptions are per-session state and a server
that terminates them on the wrong event will either leak subscriber entries or stop notifying
a client that is still connected.
The full cycle, done properly, looks like this:
Everything through the second read is protocol. The last arrow is the part that is entirely the host's problem, and it is where most implementations stop too early.
A notification is not an update to the model
The re-read leaves the host holding fresh bytes and a transcript that still contains the old ones. What happens next depends on how the host assembles a context window, and the two common architectures behave very differently.
| Host rebuilds context each turn | Host appends immutably | |
|---|---|---|
| Attachments are | re-materialised from a set of URIs | messages already in the transcript |
| Effect of a re-read | next turn carries the new document | nothing; old copy is still in the log |
| Old value still visible | no, unless quoted in an earlier turn | yes, in its original message |
| What the host must add | nothing, in the common case | an explicit supersede message |
| Residual risk | earlier turns that quoted 600 rpm | both copies present and contradictory |
| Cost per turn | one read per attached URI | one message, ~40 tokens |
A rebuilding host gets most of the way there for free, because it holds URIs rather than
bytes and resolves them when it composes the request. An appending host — which is what most
agent loops are, since the transcript is the state — will end up with two copies of
config://service/limits several thousand tokens apart, saying different things, with
nothing marking which one is current. Models handle that badly and in a specific way: the
earlier statement has been reasoned over in the intervening turns, so its numbers appear in
the agent's own prior messages, and the transcript now supports the old value in many places
and the new value in one.
That residual risk survives even the rebuilding architecture, because a turn in which the agent wrote "I will use batches of 500, which is the maximum" is a permanent part of the log. Replacing the attachment does not retract the sentence. The fix that works is to make the change explicit rather than silent, in a message the host inserts at the point of change:
{
"role": "user",
"content": [{ "type": "text", "text":
"SYSTEM UPDATE 00:52 — config://service/limits changed. max_batch is now 50 (was 500) and requests_per_minute is now 120 (was 600). Values quoted earlier in this conversation are superseded. Re-plan any in-flight batching against the new limits." }]
}Naming both the old and the new value is what makes it work. A message that only announces the new limits leaves the model to notice a contradiction; a message that says which prior value is being replaced turns a contradiction into an instruction. The instruction to re-plan matters as much, because an agent that has already computed a batching schedule will not necessarily recompute it just because an input changed.
Polling when there is no subscribe
Against a server that declares no subscribe, the client's option is to re-read on a
schedule and be honest about the window that creates. The choice of interval is a
straightforward trade between staleness and load, and it is worth writing down rather than
leaving in a constant.
| Interval | Worst-case staleness | Reads per session-hour | Reads/hour at 400 sessions |
|---|---|---|---|
| every turn | one turn, ~8 s here | ~450 | 180,000 |
| 60 s | 60 s + turn duration | 60 | 24,000 |
| 15 min | 15 min + turn duration | 4 | 1,600 |
| once, at start | unbounded | 1 | 400 |
Every figure in that table comes out of two multiplications, and doing them is what makes the table arguable rather than authoritative. The third column is an hour divided by the interval: an hour holds three thousand six hundred seconds, so a turn every eight seconds is the ~450, a sixty-second timer is sixty, and a fifteen-minute timer is four. The fourth column is the third multiplied by the fleet size, and it is worth checking one row against another rather than reading the numbers off — four hundred sessions times four hundred and fifty is the 180,000, times sixty is the 24,000, times four is the 1,600, times one is the 400. Every row holds, with no row where the multiplication comes out differently, which tells you something about how to argue with the table: the last column carries no information the third does not, because it is the same quantity scaled by a constant. So the interval is a decision you can make on the per-session column alone, and a team that grows from four hundred sessions to twice that has not changed the trade-off, only the size of both sides of it.
An agent running a turn about every eight seconds re-reads a 300-byte config 450 times per hour against a server that answers in 9 ms — 4 seconds of added latency spread across an hour, and a load the config service does not notice. For a document that fronts a slow backend the arithmetic changes and 60 seconds is the sane default. What does not change is the obligation to state the window: a host that polls every 15 minutes is promising its users a document that may be a quarter of an hour out of date, and that promise belongs in the attachment's own presentation — "limits, as of 00:47" — so that a human reading the transcript can tell whether the agent was working from current information.
The "4 seconds of added latency" in that sentence is the other multiplication, and it is the one that decides whether the aggressive option is affordable at all. Four hundred and fifty reads at 9 ms each is a little over four thousand milliseconds, which is the 4 seconds — and four seconds spread across an hour is something like one part in nine hundred of the session's wall clock, arriving in nine-millisecond pieces that no user perceives. That is why the extreme row of the table is not the absurd one it looks like. It also shows exactly which term to watch when the arithmetic stops working: the read count is fixed by the turn rate, so the only thing that can make per-turn polling expensive is the per-read cost, and a document fronting a backend that answers in hundreds of milliseconds turns the same four hundred and fifty reads into minutes.
Conditional re-reads take most of the cost out. annotations.lastModified from chapter 3
lets a client compare timestamps and skip the work of re-materialising an unchanged
attachment, though the read itself still happens, since there is no conditional read in the
protocol and no ETag to send. What you save is downstream: context reassembly, token spend on
a supersede message nobody needed, and the model re-reading a document it has already seen.
The retry loop is worth pulling apart, because it is the part of that story where a defence
was working perfectly and made things worse. An HTTP 429 says the caller has exceeded its
allowance, and the standard response to it — wait, then send again — is correct for the
failure it was written against, which is a caller going too fast. Here the allowance had two
dimensions and only one of them was the caller's speed; the other was how many items a single
request could carry, and the config change had moved that one. So the retry policy varied the
only input it knew how to vary, the delay, while the input that was actually out of bounds
stayed at 500 on every attempt. A backoff loop cannot converge on a bound it does not know
exists, and it will run indefinitely rather than fail, because each individual response is the
one it expects. The general shape is worth carrying: a retry policy encodes a belief about
which input was at fault, and it is silent precisely when that belief is wrong.
Would a tool have been better?
The counter-proposal after an incident like this is usually to delete the resource and expose
get_limits(), so the model can fetch current values whenever it wants them. There is
something real in it. A value fetched at the moment of use is fresher than any subscription
can make an attachment, because the subscription's guarantee is bounded by notification
latency and the host's turn boundary while a tool call is answered inside the turn that needs
it. For values consulted rarely, and especially for values whose relevance is itself a
mid-reasoning judgement, a tool is a defensible design.
It does not solve this incident. The migration agent would have to decide to call
get_limits(), and chapter 1's measurement is the whole problem — a model that fires a
relevant read on 71% of turns will spend the other 29% acting on what it remembers, and what
it remembers is max_batch: 500 from 21:40, stated with confidence in its own earlier
messages. Adding the tool also does nothing about the transcript, since a tool result saying
50 sits in the same log as fourteen prior messages saying 500, with no supersede note unless
someone writes one. The incident's actual failure was that a changed value never got in front
of the model at all, and moving the fetch to the model's side makes the fetch itself
conditional on the model's attention.
Notifications stampede, and transcripts do not retract
Subscriptions are per-URI, so a session watching 30 attachments holds 30 subscriptions, and a server under a config rollout that touches all of them sends 30 notifications and receives 30 re-reads from every connected client at once. Debouncing on the server, which means waiting a short interval and sending one notification for a burst of edits, coalescing on the client, which means folding several pending re-reads into one, and a cap on subscriptions per session are all ordinary engineering, and all of them widen the staleness window slightly in exchange for not stampeding.
The size of that burst is a product of two counts, and neither of them belongs to the person who caused it. One is the subscriptions a session holds, which is decided by how many documents a user attached. The other is how many sessions are connected, which is decided by the time of day. Against the four hundred concurrent sessions the polling table assumed, thirty subscriptions apiece is twelve thousand re-reads landing inside whatever window the server debounces to — from a single administrative act that touched one config. This is the argument for debouncing on the server rather than only coalescing on the client: the client can only fold together the re-reads it owns, and the multiplication that hurts is the one across clients, which no client can see.
Per-session subscription state also constrains deployment. A stdio server holds it in memory and it dies with the process, which is fine. An HTTP-transport server behind a load balancer holds it on one instance, so either the notification stream and the subscription share a connection or the state has to be externalised, and a fleet that scales in and out during an incident is a fleet that quietly drops subscribers.
The part that is genuinely unsolved is the transcript. A supersede message is a heuristic that works well and is not a guarantee, because the model is still free to attend to an earlier assertion; there is no retraction primitive in any of this, no way to mark a region of context as no longer true and have the provider enforce it. Hosts that care a great deal about it summarise and rewrite history at checkpoints, dropping the superseded values out of the log entirely, which trades fidelity of the record for consistency of the context. That trade has no settled answer.
The arrangement that holds, then, is the host re-reading — on notification where the server supports it, on a stated interval where it does not — and injecting an explicit supersede message when the value changes, with a tool reserved for the reads whose parameters the host cannot know in advance. Subscribe is the cheap half and it only carries bytes; the half that decides whether an agent behaves is the one where the host names the old value, names the new one, and tells the model to re-plan. Where the server offers no subscription the same arrangement runs on a timer instead, and the interval is a promise about staleness that belongs on screen next to the attachment rather than buried in a constant.
All of this assumes a host that reads resources at all. When it does not, none of the mechanisms in this topic reach the model, and the pressure to solve that with one more tool becomes very hard to argue with.
Resources — read the current version at https://daviddominguez.dev/learn/en/resources/resource-subscriptions-and-staleness