fix(everything): make syncRoots idempotent on error and empty paths#4107
Open
1060996408 wants to merge 1 commit intomodelcontextprotocol:mainfrom
Open
fix(everything): make syncRoots idempotent on error and empty paths#41071060996408 wants to merge 1 commit intomodelcontextprotocol:mainfrom
1060996408 wants to merge 1 commit intomodelcontextprotocol:mainfrom
Conversation
`server/roots.ts` — `syncRoots`:
The doc claims this function is idempotent and fetches roots from the
client at most once per session, but the previous implementation only
populated the cache on the success path. If `listRoots` threw or the
client returned a falsy / unshaped response, no entry was written to
`roots`, so the next call would see `!roots.has(sessionId)` and:
- re-issue the `roots/list` request to the client, and
- call `setNotificationHandler(RootsListChangedNotificationSchema, ...)`
again — once per tool invocation while the client kept returning the
same error.
Cache-write semantics now match the documented contract:
- before awaiting `listRoots`, mark the session as in-flight by writing
an empty array; this prevents a re-entrant call (e.g. a tool invoked
during the initial fetch) from installing a second handler.
- on a successful `{roots}` response, replace the placeholder with the
real list.
- on a falsy / unshaped response, keep the empty list (sync attempted,
client just has none).
- on a thrown error, keep the empty list and log to stderr; we do not
retry on subsequent `syncRoots` calls. Refresh still happens through
the `notifications/roots/list_changed` path the handler is registered
for.
Also flatten the early-return for clients without the `roots` capability
and update the JSDoc to describe the new behavior.
`__tests__/roots.test.ts` — new file, 4 tests:
- caches roots and does not re-fetch on subsequent calls
- does not re-fetch when `listRoots` throws (was: 3 calls; now: 1)
- does not re-fetch when `listRoots` returns falsy (was: 2 calls; now: 1)
- skips fetch entirely when the client lacks the `roots` capability
Full suite: 99 / 99 pass (95 existing + 4 new). `npm run build` clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
syncRootsinsrc/everything/server/roots.tsclaims (in JSDoc) to be idempotent and fetch roots from the client at most once per session, but the previous implementation only populated the cache on the success path. IflistRootsthrew or the client returned a falsy / unshaped response, no entry was written to the cache. SubsequentsyncRootscalls would then re-issueroots/listto the client and re-register thenotifications/roots/list_changedhandler — once per tool invocation while the error persisted.This change makes the cache-write semantics match the documented contract.
Server Details
everything(reference server)Motivation and Context
Two concrete issues with the old code:
listRootsrequests on transient client errors. Every call toget-roots-list(or any caller ofsyncRoots) re-hit the client. With a flaky client this turns one user-initiated tool call into N silent client round-trips.setNotificationHandlerregistration.server.server.setNotificationHandler(RootsListChangedNotificationSchema, requestRoots)is invoked on every retry. Whether this is a no-op (last-write-wins) or accumulates depends on SDK internals — either way it shouldn't be re-registered.The JSDoc already promises "This function is idempotent. It should only request roots from the client once per session." The fix aligns the code with that promise rather than weakening the doc.
What changed
In
requestRoots:roots.set(sessionId, response.roots)+ log).In
syncRoots:rootscapability (one less indent level for the rest).await requestRoots(), write a placeholderroots.set(sessionId, []). This guards against a re-entrant call landing while the initial fetch is still in flight (the second caller would otherwise see!roots.has(sessionId)and install a second notification handler).Refresh continues to come from the
roots/list_changednotification channel — that handler is registered exactly once per session.How Has This Been Tested?
New test file
src/everything/__tests__/roots.test.ts(4 tests):listRootsthrows (before fix: 3 calls, after: 1)listRootsreturns falsy / unshaped (before fix: 2 calls, after: 1)rootscapabilityFull suite for
src/everything: 99 / 99 pass (95 existing + 4 new).npm run buildclean.Manual: re-ran
get-roots-listagainst the localeverythingserver with a working client; behavior unchanged on the success path.Breaking Changes
None. Internal cache semantics only; the public tool surface (
get-roots-list) returns the same shape, including the empty-list-with-helper-text branch when the client has no roots configured.Types of changes
Checklist
Additional context
server/roots.ts, new__tests__/roots.test.ts),+148 / −53.