Chapter 5 of 7 · 14 min read
Degrading on the capability declaration
On this page
A resource in MCP is a piece of read-only data a server exposes at a URI — a file, a
record, a deploy status document — and there are three ways for a client to keep a view of
one current. The protocol offers two of them as push mechanisms and leaves the third to
you, and which are available on any given connection is decided by the resources
capability object the server sent during initialize.
That makes freshness a design decision with a declared input, and the ordinary way it goes wrong is that the input is captured and never read. The SDK stores the capability set because the SDK stores everything from the handshake; no code path in the feature branches on it; and the feature is written against whatever the development server happened to support.
Three ways to stay current, and what each costs
Here is the shape of the defect, from a live panel whose first paint is wired to a notification:
async function mountPanel(client: Client, uri: string) {
setState({ status: "loading" });
await client.subscribeResource({ uri });
client.on("notifications/resources/updated", async (n) => {
if (n.uri !== uri) return;
const doc = await client.readResource({ uri });
setState({ status: "live", doc, at: Date.now() }); // first paint happens here
});
}Against a server that supports subscriptions this is correct and pleasant to use. Against
one that declared resources: {} — resources exist here, subscriptions do not — the
status: "loading" set on the first line is a terminal state, because the only transition
out of it is a notification that will never arrive.
"Terminal" is the precise word rather than a dramatic one, and the reason is visible by
counting the exits from that state. A state machine leaves a state only along a transition
somebody wrote. In this function exactly one transition out of loading exists: the handler
registered on notifications/resources/updated, which calls setState with a document.
There is no timeout, no error branch, and no path in which a read happens for any other
reason. So on a connection where that notification cannot be produced, the number of ways
out of loading is zero, and the panel is not slow or degraded — it is finished, in the
state it was put into on the first line. The defect is not that the subscription failed;
subscribeResource may well have resolved happily. It is that the only exit from the
initial state was placed behind an event the connection was never going to deliver.
The three mechanisms are not interchangeable, and the differences are not only about
freshness. A subscription is per-resource and tells you that URI changed. A listChanged
notification is per-server and tells you the set of resources changed, which is a different
event and does not fire when the contents of a resource you are watching are edited.
Polling is a re-read on a timer and requires nothing from the server beyond the resources
capability it already declared.
resources/subscribe | resources/list_changed | Timed re-read | |
|---|---|---|---|
| Requires | resources.subscribe | resources.listChanged | resources alone |
| Signals | one URI's contents changed | the set of resources changed | nothing; you look |
| Freshness | ~300 ms in our fleet | ~300 ms, wrong event | half the interval on average |
| Requests per 8 h session | ~40, one per change | ~40 | 960 at 30 s |
| Fails by | accepting and going silent | never firing for edits | going stale, visibly |
| Server support in our fleet | 11 of 14 | 12 of 14 | 14 of 14 |
The freshness numbers come from one fleet of servers: median lag between a deploy record changing in a partner's database and the notification landing in the client was 280 ms across a week, and the tail was well under two seconds. A 30-second poll gives an average staleness of 15 seconds and a worst case of 30, which is a real degradation and an acceptable one for a status panel where the underlying event — a deploy finishing — takes minutes anyway.
Half the interval is the right average, and the derivation is one sentence once the assumption behind it is stated. A change arrives at some moment inside the gap between two reads, and nothing about the poller's clock is related to when deploys finish, so any moment in that gap is as likely as any other. A change landing immediately after a read waits the whole interval to be noticed; one landing immediately before the next read waits almost nothing; and averaged over a uniformly distributed arrival time, the wait is the midpoint of the interval. The worst case is the whole interval, which is the other figure. The assumption doing the work is the uniform arrival, and it is worth checking rather than assuming: a system whose events cluster — deploys that always go out on the hour, a batch job on a schedule — can synchronise with a poller and produce an average staleness far worse than half, or far better, depending on how the two clocks happen to line up.
The row of nine hundred and sixty is itself a division worth doing, because everything downstream of it is that figure scaled. An eight-hour session is twenty-eight thousand eight hundred seconds, and one read every thirty seconds over that span is twenty-eight thousand eight hundred divided by thirty, which is the figure in the table. Change either number and the count moves in the obvious direction: doubling the interval halves the reads, which is the sixty-second line below, and dividing the interval by six multiplies them by six, which is the five-second line.
The request cost is the number that decides the interval. Nine hundred and sixty reads per
panel per eight-hour session sounds alarming until it is priced: one open panel per user,
1,200 users, is 1.15 million resources/read calls a day against the partner's server, or
about 13 requests per second sustained. That is not free and it is not frightening either,
and it is exactly the conversation to have with the partner before shipping rather than
after.
Both steps in that pricing are worth taking apart, and the second one carries a condition that has to travel with it. The daily total is the per-session count times the number of users, since each user holds one panel for one session: nine hundred and sixty reads apiece, twelve hundred users, giving the million and a bit quoted. The per-second figure then divides that daily total by the number of seconds in a day — and that is where the assumption enters, because the reads do not happen across a whole day. They happen inside eight-hour sessions. If those twelve hundred users share one working day, the same total lands inside a third of the clock and the rate the partner's server actually sees during business hours is three times the figure quoted; spread across three continents, the quoted figure is close to right. Neither reading is wrong, and the peak is the one capacity is planned against, so the sentence to take into the conversation with the partner names both the daily total and the window it arrives in. The later five-second figure uses the same full-day denominator, so the two are comparable with each other and both understate the busy hour by the same factor.
Dropping to a 60-second interval halves it at the cost of doubling the staleness window; going to 5 seconds makes the panel feel live and makes you 80 requests per second of unannounced traffic on someone else's service.
The polling path is also the one where you can be cheap about bandwidth without being cheap about correctness. Resource contents come back with the full text, so a naive poller ships the whole document every 30 seconds even when nothing changed; hashing what you read and only re-rendering on a change keeps the render cost at zero for the 95% of polls that find nothing, though it does not reduce the request count, since the request is what discovers the change.
Gating the strategy on the declaration
The fix is a branch on the capability set, taken once at connect time, that picks the best available strategy and records which one it picked so the interface can say so.
type Strategy = { kind: "subscribe" } | { kind: "poll"; intervalMs: number };
function pickStrategy(caps: ServerCapabilities): Strategy {
const r = caps.resources;
if (!r) return { kind: "poll", intervalMs: 0 }; // no resources at all: feature off
if (r.subscribe) return { kind: "subscribe" };
return { kind: "poll", intervalMs: 30_000 };
}
async function mountPanel(client: Client, uri: string, caps: ServerCapabilities) {
const strategy = pickStrategy(caps);
if (strategy.intervalMs === 0 && strategy.kind === "poll") {
setState({ status: "unavailable",
reason: "This server does not expose deploy status." });
return;
}
// Both strategies start with a read, so the panel paints from real data
// rather than from the first notification.
const first = await client.readResource({ uri });
setState({ status: "ready", doc: first, at: Date.now(), mode: strategy.kind });
if (strategy.kind === "subscribe") {
await client.subscribeResource({ uri });
client.on("notifications/resources/updated", async (n) => {
if (n.uri !== uri) return;
setState({ doc: await client.readResource({ uri }), at: Date.now() });
});
} else {
const timer = setInterval(async () => {
const doc = await client.readResource({ uri });
setState({ doc, at: Date.now() });
}, strategy.intervalMs);
registerCleanup(() => clearInterval(timer));
}
}Two details in that function do more work than the branch itself. The first read happens
unconditionally on both paths, which means the panel paints from data in every case and the
subscription becomes an optimisation on top of a working feature rather than the thing the
feature depends on. That single change would have prevented the original hang even without
the capability check, because the terminal loading state came from making the notification
responsible for the first paint.
pickStrategy deliberately has no branch for listChanged, and the omission is
the point of having read the table. A server declaring
resources: { listChanged: true, subscribe: false } will tell you when a
resource appears or disappears, which for a deploy panel means it fires when a
new environment is added and stays silent for every status change inside an
environment already on screen. Treating that notification as a change signal
produces a panel that updates at exactly the wrong moments and looks live enough
that nobody investigates for months. Where both are declared, the useful
combination is to subscribe for contents and listen to list_changed for the
navigation tree beside the panel, which is two features consuming two different
signals rather than one feature guessing.
The second is registerCleanup. A poller is a live resource attached to a connection, and
if it is not torn down with that connection it keeps issuing reads against a client that is
closing, which is one of the ways a shutdown leak begins.
What the user sees is the third piece, and it belongs in the interface rather than in a log. The panel that is subscribed shows the value and nothing else. The panel that is polling shows the value and a quiet line beneath it — "updated 12 seconds ago" — that ticks upward and turns amber past 60 seconds. That line is not decoration; it is the honest statement that this number may be up to half a minute behind, and it is what stops a user from making a decision on a stale deploy status while believing they are watching it live.
Why calling subscribe to find out is the wrong instrument
The proposal that always arrives at this point is to call resources/subscribe once on
connect, catch the error, and fall back. It is appealing because it is one line and because
feature detection is genuinely the right pattern in browsers, where there is no negotiation
and the only way to know whether an API exists is to look for it. MCP is not that situation,
and the analogy imports a technique from a place where nothing was declared into a place
where everything was.
Since that is an analogy and not an argument, it is worth marking exactly how far it carries. It holds on the goal: in both settings the code wants to know whether a facility is available before depending on it, and in both settings guessing from a product name is the wrong way to find out. It breaks on the one fact that matters, which is that a browser never told you anything. There is no moment in a page's life at which the environment enumerates what it supports, so reaching for the thing and seeing whether it is there is not a workaround — it is the only channel that exists. MCP opens with the enumeration a browser never provides, several hundred milliseconds before any feature code runs. Importing the technique therefore means answering by experiment a question that was already answered in writing, and the cost of that is not inelegance: the written answer is authoritative for the whole session, while an experiment is only ever a report about one moment.
The first problem is that it discards a contract that was already agreed. The capability set
arrived in the initialize response, before any feature code ran, and it is authoritative
for the whole session — it cannot change while the connection is open. Probing asks a
question that has been answered, at the cost of a round trip per server per connect, and on
a host with a dozen connections that is a dozen requests whose only purpose is to re-derive
information already sitting in a field.
The second problem is worse, because it means the probe does not even detect the failure you
care about. A server can declare subscribe, accept your resources/subscribe request with
a successful result, and never send a single notifications/resources/updated — because its
change-detection is wired to one code path and the deploy record is written by another, or
because the notification fires only for resources under a URI prefix yours is not under. The
subscription succeeded. The probe passes. The panel hangs exactly as before, and now it
hangs on a server that looked correct at connect time, which is harder to diagnose than the
original bug.
The general shape of that second problem is worth extracting, because it is the reason probing is not merely redundant but actively misleading. A probe asks whether a request is accepted. The behaviour the panel depends on is whether a notification is sent, later, by a different piece of the server's machinery. Those are two different claims about two different code paths, and nothing connects them: accepting a subscription means writing an entry into a table, and sending a notification means some other component consulting that table when it changes something. A server can do the first perfectly and never do the second, which is exactly the case described. So the probe returns a confident yes about a question nobody asked, and the confidence is the damage — a declaration that says nothing at least leaves you looking, whereas a successful probe closes the investigation. Whenever a check and the behaviour it is standing in for are separated by a gap like that, the check can only ever be a necessary condition, and treating a necessary condition as a sufficient one is the shape of most false confidence in a distributed system.
Guarding against silence needs a different mechanism entirely, and it is worth building where the data matters: a watchdog that records the time of the last notification, and if a subscribed panel has heard nothing for, say, ten minutes on a resource that normally changes several times an hour, does one re-read and compares. If the contents differ, the subscription is broken and the panel should switch itself to polling and say so. That is a fallback triggered by observed behaviour rather than by a declaration, and it sits on top of the capability gate rather than replacing it. The two mechanisms answer different questions — the declaration says what was agreed, the watchdog says what is actually happening — and a client that wants live data over a fleet of servers it does not control needs both.
The limit of what a declaration can promise
Degrading on the declaration handles the mismatch you can see, and it does not make a client
robust in general. It says nothing about a server that declares tools and returns an empty
list, or one whose resources are present and whose contents are hours out of date at the
source, or one that becomes unreachable mid-session. Those are liveness and quality problems
and they need timeouts, health signals and user-visible state, none of which the handshake
supplies.
There is also an asymmetry worth naming. Servers and clients ship on independent schedules, so a client written against today's capability keys will meet servers declaring keys it has never heard of, and the correct response to an unknown capability is to ignore it. The reverse — a client using a method the server did not declare — is the failure this chapter is about, and the two rules together are the ordinary robustness principle applied to a protocol whose whole design assumes the two ends were built by different people who never spoke.
Read the declaration, branch on it once, and make the degraded path a real path rather than an error state. That is three instructions and the middle one is where the work is: a strategy chosen at connect time, recorded on the connection, and visible to the interface, so that the feature has one place where the difference between fourteen servers is resolved instead of fourteen places where it is discovered.
Both the subscription and the poll timer are live state hanging off a connection, and the
registerCleanup line above quietly assumed something that has not been established: that
there is a moment when a connection is deliberately taken down, with a defined order of
operations, and someone whose job it is to run them.
Architecture — read the current version at https://daviddominguez.dev/learn/en/architecture/degrading-on-the-capability-declaration