Skip to content
Chapter 5 of 6 · ResourcesResource subscriptions and staleness

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:

json
{
  "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.

json
{ "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:

Sequence: Host → Config server (resources/subscribe config://service/limits); Config server → Host (ok); Host → Config server (resources/read); Config server → Host (max_batch 500, rpm 600); Config server → )H (notifications/resources/updated); Host → Config server (resources/read); Config server → Host (max_batch 50, rpm 120); Host → Provider (next turn, with the new document and a supersede note).

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 turnHost appends immutably
Attachments arere-materialised from a set of URIsmessages already in the transcript
Effect of a re-readnext turn carries the new documentnothing; old copy is still in the log
Old value still visibleno, unless quoted in an earlier turnyes, in its original message
What the host must addnothing, in the common casean explicit supersede message
Residual riskearlier turns that quoted 600 rpmboth copies present and contradictory
Cost per turnone read per attached URIone 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:

json
{
  "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.

IntervalWorst-case stalenessReads per session-hourReads/hour at 400 sessions
every turnone turn, ~8 s here~450180,000
60 s60 s + turn duration6024,000
15 min15 min + turn duration41,600
once, at startunbounded1400

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

Back to top