Follow-up to a review thread on #71 (#71 (comment)).
The ordering half of that finding is fixed: a roots budget expiring no longer rethrows past the
availability fallback, because the cancellation arm in McpServerHandler now asks whether the
caller withdrew. What remains is the case where there is no fallback to lose.
The residual
On a cold initialize-era connection — no snapshot cached yet — a client that declares roots
and then answers roots/list slowly or never makes every tools/list and tools/call stall for
the full ten-second RootsRequestBudget and then fail. JoinOrStartAsync retracts the faulted
task and nothing negative-caches, so the next request starts a fresh budget. SnapshotGate is held
across it, so concurrent requests on that connection serialize and each pays its own ten seconds.
_primeFailedAt and the thirty-second PrimeRetryCooldown exist for exactly this, but they gate
PrimeCurrentAsync only. GetAsync reads them nowhere, and CallToolAsync reaches
GetSnapshotAsync before the adapter's PrimeFromServicesAsync runs, so the prime's cooldown
cannot rescue a tool call either.
Why it is not a one-line change
Routing the legacy pre-resolution through PrimeFromServicesAsync would cap the cost at one budget
per thirty seconds — it already rethrows only the caller's cancellation and applies the stand-down.
But with roots unresolved the graph would be built as "no roots" and published as current for
that version, so a reduced catalog would be cached until the next routing bump. That is a behaviour
change, not a pure fix. Publishing it stale instead is probably the right shape, and deciding that
deliberately is the point of this issue.
No test covers budget expiry today: the one slow-roots guard uses a five-second client delay under a
ten-second budget, on the request-scope path, and asserts caller-token release rather than expiry.
Follow-up to a review thread on #71 (#71 (comment)).
The ordering half of that finding is fixed: a roots budget expiring no longer rethrows past the
availability fallback, because the cancellation arm in
McpServerHandlernow asks whether thecaller withdrew. What remains is the case where there is no fallback to lose.
The residual
On a cold initialize-era connection — no snapshot cached yet — a client that declares
rootsand then answers
roots/listslowly or never makes everytools/listandtools/callstall forthe full ten-second
RootsRequestBudgetand then fail.JoinOrStartAsyncretracts the faultedtask and nothing negative-caches, so the next request starts a fresh budget.
SnapshotGateis heldacross it, so concurrent requests on that connection serialize and each pays its own ten seconds.
_primeFailedAtand the thirty-secondPrimeRetryCooldownexist for exactly this, but they gatePrimeCurrentAsynconly.GetAsyncreads them nowhere, andCallToolAsyncreachesGetSnapshotAsyncbefore the adapter'sPrimeFromServicesAsyncruns, so the prime's cooldowncannot rescue a tool call either.
Why it is not a one-line change
Routing the legacy pre-resolution through
PrimeFromServicesAsyncwould cap the cost at one budgetper thirty seconds — it already rethrows only the caller's cancellation and applies the stand-down.
But with roots unresolved the graph would be built as "no roots" and published as current for
that version, so a reduced catalog would be cached until the next routing bump. That is a behaviour
change, not a pure fix. Publishing it stale instead is probably the right shape, and deciding that
deliberately is the point of this issue.
No test covers budget expiry today: the one slow-roots guard uses a five-second client delay under a
ten-second budget, on the request-scope path, and asserts caller-token release rather than expiry.