fix(core): register peer-request handlers before connect (#1797)#1798
Conversation
The roots/sampling/elicitation capabilities are advertised on the SDK `Client` at construction, so from the moment `connect()` sends `notifications/initialized` the server may issue any of those requests. The handlers, however, were registered after the handshake resolved and after `fetchServerInfo()` — leaving a window in which the SDK answered `-32601 Method not found`. `server-filesystem` asks for `roots/list` the instant it is initialized (that is how it learns its allowed directories) and lost the race, so it silently fell back to its CLI-argument directories instead of the roots configured in the Inspector. `server-everything` asks later and did not. Extract the four peer-request handler blocks into `registerPeerRequestHandlers()` and call it before `client.connect()`. None of them depend on server capabilities — only on constructor-set state. The notification handlers that do gate on `this.capabilities` stay in `connect()`, after `initialize`. Regression test drives the race deterministically with a fake transport that delivers `roots/list` synchronously from inside the `send()` of `notifications/initialized`; it fails on the pre-fix ordering. Closes #1797 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr
|
@claude review |
|
Claude finished @cliffhall's task in 3m 3s —— View job Review:
|
…test (#1797) Finding 1: `notifications/roots/list_changed` was the one handler in the post-handshake block that gates on nothing but constructor state, so it sat in the same race — a server emitting it right after `initialized` had it silently dropped (no wire error; the symptom is a missed UI refresh). Moved to a new `registerPeerNotificationHandlers()` called next to `registerPeerRequestHandlers()` before `connect()`. Finding 2: the timing test asserted one of the four moved handler blocks. It now injects `tasks/list` and the `roots/list_changed` notification in the same burst as `roots/list`, so moving any of the block back after `connect()` fails the test. Sampling/elicitation stay out — they park a pending request awaiting user input and have no reply to assert on. Dropped the unused `sessionId`/`setProtocolVersion` transport fields. Verified the added assertions are load-bearing: the notification assertion fails against the previous commit, the `roots/list` one against `origin/v2/main`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr
|
Both findings taken, in 1.
|
|
@claude review |
|
Claude finished @cliffhall's task in 3m 1s —— View job Review (round 2):
|
The round-1 doc comment claimed a server may emit `notifications/roots/list_changed` at initialize. It can't, normally: `RootsListChangedNotificationSchema` is a member of `ClientNotificationSchema` and absent from `ServerNotificationSchema` — the client sends it (setRoots does, at :4251). The inbound handler is defensive coverage for a non-conformant server, and dispatches `rootsChange` with our own already-known roots. Reworded the method doc and the test's header so neither states the false premise; the move itself stands (it gates on no server capability, so it belongs with the request handlers). Also from review: - Dropped the vacuous `notificationRejected` flag and its inverted comment. The SDK does not throw for an unregistered notification, so the try/catch could never observe one — confirmed by bisect, where the pre-fix failure was always `rootsChanges`, never the flag. Removing the catch also stops it swallowing a genuine throw from the injected requests. - Narrowed `replies` to responses (`"result" in message || "error" in message`) so it can't also collect client-originated requests. - Annotated the unreachable `if (!this.client)` guard with a justified `v8 ignore` per the AGENTS.md dead-guard policy. Re-verified both regression directions: the `roots/list` assertion fails against `origin/v2/main`, the `rootsChange` one against `55e25b7c`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr
|
All three taken, in 1. Correction accepted — and you're rightI verified it against the installed SDK rather than taking it on trust: Reworded the method doc to say what's true: it's a The move itself stands, for the reason you gave. 2.
|
|
@claude review |
|
Claude finished @cliffhall's task in 2m 16s —— View job Review (round 3):
|
…1797) The four inline comments moved verbatim with the handler contradicted the method doc reworded one round earlier: they said "server's roots" (they are ours — it is a ClientNotification) and "we'll just dispatch an empty array", which is not what `this.roots || []` does when roots are configured. The new timing test asserts exactly that non-empty dispatch. Replaced with one accurate line. Comment-only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr
|
Taken, in You're right that both halves were wrong, and the second one is the sharper catch: // Re-dispatch our already-known roots as a refresh signal for the UI —
// the payload carries no new data (see the note on ownership above).That's the last of the review items. Agreed on leaving |
|
@claude review |
|
Claude finished @cliffhall's task in 2m 29s —— View job Review (round 4):
|
Round-4 review found the same -32601 symptom on a second path: the CLI
constructed its InspectorClient with no `roots` option, so
`capabilities.roots` was never advertised and no `roots/list` handler was
registered — yet `--method roots/set` calls `setRoots()`, which announces
`notifications/roots/list_changed` to the server. A server taking up that
invitation got "Method not found", after the CLI reported success.
The review's suggested fix — register `roots/list` unconditionally — is
not implementable: the SDK asserts the client capability inside
`setRequestHandler` ("Client does not support roots capability"), so it
throws during connect for any client built without the option, and
`registerCapabilities` refuses to run after connect. A client that omits
`roots` therefore cannot legally serve `roots/list` at all.
Fixed at the layer that can fix it: `cli.ts` seeds `roots: []`, matching
web (`App.tsx` always passes it). The core registration stays gated on
the constructor value, now with the SDK constraint documented so the next
reader doesn't retry the unconditional version.
`setRoots()`'s doc claimed it "will enable roots capability if it wasn't
already enabled" — it cannot, for the reasons above. Corrected, and
dropped the dead `if (this.roots === undefined) this.roots = []` the very
next line overwrote.
Tests: a CLI test drives `list_roots` over an HTTP test server (verified
load-bearing — without the seed it fails on the -32601 error text), and
the core timing test gains a case pinning that roots set after connect
are served with the current values, not the constructor's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr
|
Handled in this PR rather than as a follow-up ( The finding is real — I reproduced itWrote the test first, on a client constructed without But "register
|
|
@claude review |
|
Claude finished @cliffhall's task in 4m 19s —— View job Review (round 5):
|
Round-5 review: seeding `roots: []` fixed the -32601 but not the outcome. `server-filesystem` discards an empty roots answer (`validatedRootDirs .length > 0`) and keeps its CLI-argument directories — which is the symptom this PR set out to fix. The user's roots were already in hand two lines away, on `serverSettings.roots` (lifted from mcp.json by `mcpConfigToServerEntries`). Now passed through `cleanRoots`, the same shared helper web uses, so both clients answer `roots/list` byte-identically for the same config. This matters most on the CLI because it is one-shot: `--method roots/set` sets roots on a connection torn down a few statements later, so the config file is the only durable way to give a run its roots. Same gap in the TUI (`App.tsx` built its options with no `roots` key at all), fixed the same way. The review suggested a follow-up issue for it; folding it in here instead, since it is the identical line and splitting it would leave a known-wrong client in tree. Also from review: hoisted `waiters` up with the other fields, and `injectRequest` now rejects after 1s naming the method and id rather than hanging to the vitest timeout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr
|
All three taken, in 1. Should-fix — you're right, and this was the half that matteredThe Now Your point about the CLI being one-shot is the part I'd have missed: New CLI test writes a config with a 2. TUI — folded in here rather than filedI'd normally take the follow-up, but a known-wrong client sitting in tree behind an issue is worse than a three-line commit, and it's literally finding 1's line. Your reasoning for the lower severity is right — the TUI never calls On the constructor-default option: agreed it's not obviously better. 3. Nits
On the
|
|
@claude review |
|
Claude finished @cliffhall's task in 4m 10s —— View job Review (round 6):
|
…nRoots (#1797) - clients/cli/README.md enumerated the settings lifted from a catalog/config file without roots. Roots are the least discoverable member of that list — there is no flag, and `--method roots/set` dies with the connection — so the file is the only durable way to supply them. Said so. - cleanRoots() trusted its input's shape. `Root[]` is a compile-time type over hand-editable mcp.json, and as of this PR all three clients feed it straight from disk, so `"roots": [{"name":"Work"}]` or `"roots": "file:///work"` threw at connect. Non-array bails to []; an entry without a string uri is dropped with a warning. Hardened in the one shared place rather than three call sites. Review suggested a follow-up issue; folded in for the same reason as the TUI fix last round. - Test comment said the CLI passes a `roots: []` seed — true one commit ago. - injectRequest clears its timeout on reply (an armed timer outliving a test is the #1760 teardown class), and setRoots' doc drops the "despite what this comment used to claim" archaeology. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr
|
All four taken, in 1. CLI README — fixedRight call under the doc-maintenance rule, and your framing is the reason it matters: roots are the one lifted setting with no flag, so a user who doesn't read the config-file paragraph has no way to discover the capability exists. Added them to the enumeration plus a sentence saying the file is the only durable route, with the 2. Stale test comment — fixedFair, and you're right that this is the same thread as rounds 2–4. Reworded to make the point without the outdated specifics: passing the 3.
|
|
@claude review |
|
Claude finished @cliffhall's task in 2m 24s —— View job Review (round 7):
|
) The round-6 hardening guarded `uri` and left `name` throwing one line later: `r.name?.trim()` guards null/undefined, not "is a string", so `"roots": [{"uri":"file:///a","name":42}]` still died at connect — the same crash class, one field over, reachable from all three clients and the web settings-save path. Reproduced against the shipped function body before fixing. A bad `name` now costs the name, not the root (it is optional, and the uri is still usable), with a warning. The doc no longer overstates what is validated. Also from review: the CLI test comment said "`cli.ts` now seeds `roots: []`" — true only of that ad-hoc test's path; and the registration comment's `(:588)` line reference is now a durable name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr
|
Claude finished @cliffhall's task in 7m 50s —— View job Review (round 51): final tree after
|
| site | current text |
|---|---|
inspectorClient.ts:1550-1551 |
"for the paths that drop a queue without going through disconnect() — a failed connect() and a mid-session transport close" |
inspectorClient.ts:1534-1535 |
"emits them immediately for the paths that end a connection without going through disconnect()" |
elicitationCreateMessage.ts:105-107 |
"clearPendingPeerRequests(), which serves all three teardown paths" |
samplingCreateMessage.ts:91-93 |
"clearPendingPeerRequests(), which serves all three teardown paths" |
Plus rejectPendingRawWireRequests's one-liner (:2298): "(on disconnect/teardown)", which now also runs at connect start.
The two class-level ones matter most: cancel()'s doc is where someone asks "who calls this and when," and it now answers with a count that is wrong and a category ("teardown") that excludes the new caller. Round 23's diagnosis applies directly — an enumeration that counts will keep drifting; naming the category ("every route out, plus the top of connect() as a backstop for the route in that settles nothing") doesn't.
3. The synchronous-throw arm the fold newly fixed at the refresh sites is untested
Round 49 pinned both failure modes for resetSubscriptionStream (the table-driven pair at inspectorClient-peer-handler-timing.test.ts:1033-1078). Round 50 extended the same protection to refreshModernSubscription's two sites — and that's where the fold actually changed behaviour, since await previous.close().catch(() => {}) never caught a synchronous throw. No test drives it: grep -rn "modernSubscription" clients/web/src/test returns only the two seeded-stub sites in this file and the era-suite's read at inspectorClient-subscriptions-era.test.ts:291-294.
So reverting :4721 to the old form restores the described bug — a stream leaked with its reference already dropped, and the refresh aborted before its replacement opens — with a green suite. subscribeToResource on a modern connection is the reachable driver, so the test is a subscribe with a poisoned modernSubscription seeded first, asserting the replacement listen() still happened.
4. Two comment nits
-
The new raw-wire test borrows a safety argument that doesn't hold for it.
inspectorClient-peer-handler-timing.test.ts:1004-1006: "As in the crash-path case above: … safe only because no SDK request is issued after connect." That test issues no second connect; this one callsconnect()twice, and the second one issues aninitializeunder the client-wide 50 ms budget. It's safe for a different reason —ElicitAfterConnectTransport.send()answersinitializesynchronously from insidesend(:297-312), so no timer is ever in play. The precise version matters here for the same reason the original comment spelled it out at:686-693: a fixture that later answeredinitializea macrotask later would flake, and this comment says there's nothing to check. -
resetSessionScopedUiState's new comment gives "the default" two meanings four lines apart.clients/web/src/App.tsx:1226-1227says clearingactiveServerIdRef"would silently drop this back to the default" — but the no-server branch is: null, i.e. Off, and:1231says so explicitly ("Only 'no server at all' means Off"), while:1230uses "the default" to meanDEFAULT_MODERN_LOG_LEVEL. "…would silently drop this to Off" is the fix.
5. Also checked, nothing to raise
closeSubscriptionBestEffort's doc opens "Every close in this file goes through here" (:249). There are twothis.client?.close()calls that don't (:1157,:1959); the following clause scopes it to the three stream sites, so it reads correctly in context — just an over-broad opener. Not worth a round.SamplingCreateMessage.cancel()rejects with"Connection torn down", which is now also the message a connect-start sweep produces, where nothing is being torn down. Reaches only the receiver-task payload promise (marked handled at:1056) or an SDK error response on a transport that is about to be re-initialized, so it's cosmetic.- The connect-start sweep dispatches before
status = "connecting"(:1711), so apendingElicitationsChangelistener sees the outgoing status.usePendingClientRequestsonly stores the array, andApp.tsxderives the modal from length with no status gate, so nothing reads the pair — which is the whole point of the fix. - The two dispatch payloads are the internal arrays by reference (
:1570-1574), consistent with every other dispatch site in the file rather than an outlier. - The rest of the stack is unchanged since round 45 — one
client.connect(call site (:1725) with both registrations before it (:1717-1718) inside the sametry; six capability flags all derived from the builtcapabilitiesobject with every gate reading a flag; fourcleanRootsentry points with bothKeep:guards intact and no constructible input that throws; bothcancel()implementations settling withoutonRemove. - Coverage.
closeSubscriptionBestEffort's catch is covered from theresetSubscriptionStreamside; the two new tests cover both new sweep call sites and both arms of the announce guard.
On fifty-one rounds
Round 50's own framing is the useful one: "the routes out do not cover every route in." Finding 1 is that observation applied one level further — the sweep's placement now depends on a call three lines above it, and the reason lives only in the interaction. Finding 2 is the enumeration class again, and it's now five docs describing a set that grew by one in the same commit; naming the category instead of counting is what stops the sixth occurrence.
This checkout has no
node_modules, so I couldn't run the suite; the reported gate results are taken as reported. Everything above is from reading the final tree and diffingorigin/v2/main...HEAD.
· branchv2/fix-roots-handler-race
The connect-start sweep must sit after `resetSessionState()`, and the two calls read as independent. Cancelling a task-augmented peer request settles it synchronously into the record callback, which ends in `upsertReceiverTask` — a no-op only because `clearReceiverTasks()` just emptied the map. Hoisted, it would emit a `notifications/tasks/status` for the outgoing session's task onto the transport the connect is about to reuse. Stated at the call and pinned by a test (which fails on the hoist). Adding that third caller also made five docs stale, all describing the old set by count or as "teardown" — the class round 23 diagnosed. All five now name the category: every route out, plus the top of `connect()` as a backstop for the one route in that settles nothing. And the site where round 50's fold actually changed behaviour — `refreshModernSubscription`'s first close, where the reference is already dropped and a `.catch()` never caught a synchronous throw — is now tested from the public surface (a re-list over a poisoned stream), table-driven over both arms. Verified to fail on the pre-fold form. Closes #1797
|
All four taken, in 1. The ordering dependency — stated and pinnedYou traced this correctly and it's the sharpest kind of finding: two adjacent calls that read as independent, where the second is a no-op because of the first. The clause is now on the sweep. I also took the test you suggested, since a comment saying "must stay after" is the kind of thing a refactor reads past: it queues a task-augmented sample, drives the 2. Five stale enumerationsRight, and the count was the lesser problem: 3. The refresh-site arm — tested, and confirmed load-bearingThis was the right thing to push on. Round 50's message claimed the fold fixed Checked rather than assumed: reverting 4. Both nits
Also folded in your Gate
|
|
@claude review |
|
Claude finished @cliffhall's task in 5m 24s —— View job Review (round 52): final tree after
|
…1797) The sweep's comment named as its harm the exact write the sweep performs: on this route the SDK's transport is still live, so cancelling a queued peer request emits a response for the old id immediately — which is right, and is the settle-don't-discard rule, at the best available moment. As written it read as an argument against emitting the frame, inviting the clear-without- settling regression rounds 24–25 removed. It now names the real hazard: a *user*-authored answer landing arbitrarily later, past the re-handshake. `closeSubscriptionBestEffort`'s "at each site" held at two of three — the superseded-generation discard stored nothing and has nothing following it, so its escaping failure would only reject an already-superseded `subscribeToResource`. Scoped, still wrapped. Also moves the `disconnect()`-side close pair next to its `connect()`-side twin, and states which axis the file's ordering follows, since route and category both describe it and cut differently. Closes #1797
|
All three taken, in 1. The comment argued against the settle it performsThis is the one I'm glad you traced to the wire rather than reading past. You're right: And your reason for fixing it rather than shrugging is the right one: as written, the change it invites is clear-without-settling, which is the exact regression rounds 24–25 removed. The clause now names what is actually prevented — a user-authored answer landing arbitrarily later, past the re-handshake, for a request the previous session raised — and says plainly that the sweep instead answers it now, with a cancel, while the old connection is still on the wire, which is the best moment available. 2. "At each site" held at two of threeCorrect, and the third is different in kind, not just degree: the superseded stream was never stored and the next statement is 3. Ordering, and the axis noteMoved — the Took the header line too, since as you note this residue has recurred: the file now says which axis the ordering follows (route), and that a case which is the counterpart of one in another group is placed next to its counterpart rather than with its route. That's the rule the file was already following implicitly — writing it down is what stops the sixth occurrence. Gate
|
|
@claude review |
|
Claude finished @cliffhall's task in 5m 3s —— View job Review (round 53): final tree after
|
| site | shape | what an escaping failure does |
|---|---|---|
:1475 resetSubscriptionStream |
void closeSubscriptionBestEffort(closing) — fire-and-forget, last statement |
unhandled rejection |
:4745 refreshModernSubscription |
await …, re-listen follows |
rejects the method → re-listen skipped ✅ |
:4761 superseded-generation discard |
await …, return follows |
rejects an already-superseded subscribeToResource ✅ (the new "milder" clause) |
:1475 can't produce the harm the doc assigns it, and the reason is structural: closeSubscriptionBestEffort is async, so a synchronous throw from a third-party close() is captured into the returned promise rather than propagating — and the call site voids that promise. Strip the helper's try/catch and resetSubscriptionStream still returns normally, disconnect() still runs :2013-2030, and nothing is skipped. What you get instead is an unhandled rejection.
That matters more than a mis-sorted example, because this PR already established (rounds 25–26, the payloadPromise fix) that an unhandled rejection is the process-fatal class in Node — the reason void payloadPromise.catch(() => {}) was worth putting at the source. The doc doesn't mention that harm anywhere, so the one site whose failure mode is the severe one is described as having the mild one. The local comment at :1471-1474 has the same tension inside four lines: "fire-and-forget because nothing downstream depends on it" and "disconnect() calls this from the middle of a straight-line teardown that runs outside any try" can't both be the reason — the first is why the second doesn't apply.
2. Consequence: the disconnect()-route close loop's assertions hold with the protection removed
inspectorClient-peer-handler-timing.test.ts:960-1006 asserts await expect(client.disconnect()).resolves.toBeUndefined() and that a downstream teardown step ran (controller.signal.aborted). Its comment states what it means to pin:
an escaping failure would skip the raw-wire rejects, the paused task-input aborts, the in-flight tool-call abort and
clearReceiverTasks()— silently, since most callers catchdisconnect(). […] aclose()that fails does not take the rest of teardown with it.
By finding 1, that can't happen at this site regardless of the helper's catch: both close variants become a rejected promise from an async helper, the void drops it, and disconnect() proceeds. So both halves of the loop pass with the try/catch deleted — the assertions are satisfied either way. (Vitest would likely still redden the run by reporting the unhandled rejection, so it isn't invisible; but it fails as an unattributed run-level error, not as the assertion that names the mechanism — which is the distinction rounds 15/25/32 kept applying to this file.)
What the loop does pin is a different regression: unwrapping the call to a bare void closing.close(), where the sync-throw half would then propagate. Worth keeping for that, but the assertion for the stated claim is "no unhandled rejection" — an unhandledrejection / process.on("unhandledRejection") spy around the disconnect(), or dropping the void and awaiting the reset so a rejection is observable.
The parallel is exact with the round-12 fixture and the round-5 HTTP variant: an absence assertion is only as good as the proof the thing could have been present.
3. The move traded one broken back-reference for another (nit)
:1009 — "Same route as the test above, for the other collection it strands" — the test immediately above is now the disconnect()-route close loop, not the onerror-route closes a live listen stream the next connect drops (:912) it means. :1017's "Counted for the same reason as above" is now two tests up rather than one (vaguer, so it survives, but it's the same drift).
Fixing the referents is a two-word change. The more durable read is that this file has now broken a positional reference twice — :987 before this commit, :1009 after — for the same reason round 17 rewrote the header to stop counting tests: naming the test ("the same route as closes a live listen stream the next connect drops") is immune to the next insertion in a way "the test above" isn't, and the header's own ordering note guarantees more insertions between route groups.
4. Two triviata
inspectorClient.ts:1611is 106 chars in a comment block whose other lines wrap at ~78 — the reword spliced a sentence and left the seam. Prettier'sproseWrap: preservewon't reflow it andformat:checkwon't complain. Same class as rounds 26/28/32; the fix is the same (reflow the paragraph in one pass). The file's pre-existing floor is high (there are 118- and 167-char lines), so this is cosmetic."the old connection is on the wire untildropCachedTransport()below"(:1610-1611) reads as though that drop always runs. It's conditional on:1629-1636(HTTP + OAuth + authorized + no auth provider + not enterprise-managed), so on the stdio route the tests use it never fires and the transport stays live through the reconnect. The claim is true-but-understated rather than wrong.
5. Also swept, nothing to raise
closeSubscriptionBestEffortreally does absorb both modes — theawaitis inside thetry, so a synchronousclose()throw and a rejected promise both land in the samecatch. The helper itself can never reject.:4745's re-listen claim is exact — the reference is dropped at:4743and theclient.listen(...)at:4754is the work an escaping failure would skip.- The
disconnect()clear-before-announce ordering is unchanged —clearPendingPeerRequests()at:1998above thedisconnectdispatch at:2005, matching the crash path; the raw-wire reject stays at:2020with its documented "the user asked to disconnect" rationale. - The
connect()sweep is idempotent on the overlapping routes as its comment claims —clearAndAnnouncePendingPeerRequestsguards on a non-empty queue,rejectPendingRawWireRequestsclears its map and re-rejecting a settled promise is a no-op. - Coverage is neutral for
aba28283— comments plus a moved test block.
On fifty-three rounds
Round 52 set out to scope two over-broad claims, and got one of them: the sweep comment now names the right hazard. The close doc's scoping picked the wrong site to call mild — and the interesting part is that the site it mis-describes fails in the mode this PR itself proved is fatal in Node, with a test whose assertions don't distinguish the two. That's the same "an absence assertion needs proof the thing could have been present" check that caught the round-5 and round-12 fixtures, arriving last at the newest test in the file.
This checkout has no
node_modules, so I couldn't run the suite; the reported gate results are taken as reported. Findings 1 and 2 are from the code's structure (anasyncfunction converts a synchronous throw in its body to a rejection;voidon the returned promise drops it), not from execution.
· branchv2/fix-roots-handler-race
The doc grouped `resetSubscriptionStream`'s close with the "takes the caller's remaining work with it" sites. It can't: the call is `void`-ed onto an `async` helper, so even a synchronous `close()` throw becomes a dropped rejection — `disconnect()`'s teardown runs regardless. The harm there is an *unhandled* rejection, which this PR already established (the payloadPromise fix) is the process-fatal class in Node — so the one site with the severe failure mode was documented as having the mild one. Each site now carries its own consequence, and the local comment no longer gives two reasons that cancel each other. The test had the same gap: both its assertions hold with the helper's try/catch deleted. It now also asserts no unhandled rejection fired, which does fail without the guard (verified on both arms); the teardown assertions stay, since they still catch unwrapping the call to a bare `close()`. Also names the two back-references the round-52 move left positional, and softens the "on the wire until dropCachedTransport()" claim — that drop is conditional and never runs on stdio. Closes #1797
|
All four taken, in 1. The mis-assigned site — and it was the severe oneYou're right, and the mechanism is exactly as you state it: So the doc had it backwards where it mattered most. Each site now carries its own consequence rather than sharing a pair of harms: the fire-and-forget reset (unhandled rejection — fatal in Node, a console error in the browser), the re-listen close (replacement 2. The test proved nothing at that siteCorrect — both assertions hold with the guard deleted, for the same structural reason. It now also installs an Verified rather than assumed: with the helper's 3. Positional references, againBoth now name their referent — 4. Both triviata
Gate
|
|
@claude review |
|
Claude finished @cliffhall's task in 5m 3s —— View job Review (round 54): final tree after
|
| caller | what a rejection does |
|---|---|
subscribeToResource (:4945) |
caught, rolls back the optimistic add + stream state, rethrows — as the bullet describes |
unsubscribeFromResource (:4989) |
caught, rethrown as Failed to unsubscribe from resource with no rollback |
reconnect timer (:4883) |
.catch(() => this.onModernReconnectFailed()) — counted as a failed re-list and retried |
The middle one is the reason to raise this. unsubscribeFromResource's own comment says the removal is deliberately kept even when the re-listen fails ("the stale URI simply lingers in the server's honored filter") — so on that path a close() throw surfaces a false failure to a user whose unsubscribe actually stuck and whose replacement stream is fine. That's a worse consequence than the one the bullet assigns to the site, and it's the site the bullet calls mildest.
Two smaller precision points in the same two bullets:
- "whose replacement had already succeeded" — the generation bump only proves a newer refresh started. It can still fail at its own
listen(), in which case the discard's rejection is a second failure on top rather than a redundant one. "whose replacement had already superseded it" is what the code guarantees. - Bullet 2 omits the reconnect caller too. "Skips the re-listen and leaves a non-empty subscription set with no stream" is right for the user-initiated paths; on the reconnect path the same escape is absorbed into the backoff run and retried — self-healing, i.e. a third consequence at that call site, not the one stated.
Given the commit's own thesis is that the sites' consequences "are not interchangeable," naming the second caller (and softening the "had already succeeded") is the same correction one level along.
2. The unhandledRejection capture is global but attributed locally (nit)
inspectorClient-peer-handler-timing.test.ts:1023 asserts unhandled is empty — i.e. that nothing anywhere in the process rejected unhandled during the window. Within a sequential test file that's almost always this site, but the assertion's failure message would blame this test for a rejection a prior test left pending and Node reported late (this file has several tests that arm and race timers). Filtering to the fixture's own reason makes the attribution match the claim, at no cost to the regression it catches:
expect(unhandled.filter((r) => String(r).includes("close blew up"))).toEqual([]);3. The listener registration sits outside the try that removes it (nit)
:988 registers, :1013 opens the try, :1024 removes in finally. Between them: new InspectorClient(...), await client.connect(), and two private-state seeds. Any throw there leaks the listener into the rest of the file, where it silently accumulates into an array nobody reads (and the loop makes two of them). Moving the process.on to the first line of the try, or wrapping from registration onward, closes it.
4. Also checked, nothing to raise
processis available under happy-dom, and vitest's ownunhandledRejectionhandler is additive — so the new listener neither suppresses vitest's reporting nor depends on it. The comment's "ends a Node process by default" is scoped to production, correctly; in-test Node won't die precisely because listeners exist.- The round-52/53 back-reference de-positioning holds — the two "same route as the test above" references now name
closes a live listen stream the next connect dropsandaborts a paused task-input wait when the session ends, both of which exist under those exact titles. - The softened
dropCachedTransport()claim is right: that drop is gated onisHttpOAuthConfig()(:1648-1656), so on stdio it never runs and the same transport does carry through the re-handshake. resetSubscriptionStream's dispatch ordering is still the documented one — both axes moved before either dispatch, so no listener can observe an empty set with anactivestream.- The
roots/list_changedgate readsrootsListChangedCapabilityAdvertised(:4625), the predicate the SDK asserts, not the widercapabilities.rootspresence. - Coverage is neutral-to-positive this round — the doc is free, and the new listener/assertion add no production lines.
On fifty-four rounds
The code has been converged since the mid-teens; what the last dozen rounds have been finding is prose that asserts more than the code guarantees, and this round is the same shape one level in — a paragraph written specifically to stop over-generalising, which generalises over one caller of two. Worth doing because unsubscribeFromResource's false error is a real (if narrow) user-visible consequence sitting under the label "mildest of the three"; not worth another round after that.
This checkout has no
node_modules, so I couldn't run the suite; the reported gate results are taken as reported. Everything above is from reading the final tree and diffingorigin/v2/main...HEAD.
· branchv2/fix-roots-handler-race
`refreshModernSubscription` has three callers, and the superseded-generation discard's failure reaches all of them. From `unsubscribeFromResource` — which deliberately keeps the removal when a re-listen fails — it surfaces "Failed to unsubscribe" for an unsubscribe that actually stuck, which is worse than the consequence the doc assigned to the site it called mildest. The re-listen bullet had the mirror gap: on the reconnect caller the escape is absorbed into the backoff run, not a set left with no stream. Both now name their callers, and the generation-bump claim is softened to what it proves (a newer refresh *started*). Test: the `unhandledRejection` listener is process-wide, so the assertion now filters to this fixture's own reason rather than failing on a rejection some other test left pending; and it is registered inside the `try` whose `finally` removes it, so a throw in the setup can't leak it into the rest of the file. Re-verified the filtered form still fails on both arms with the helper's try/catch removed. Closes #1797
|
All three taken, in 1. "Mildest of the three" was true of one caller out of threeYou're right, and the one it misses is the one that matters: Both bullets now name their callers rather than generalizing over them:
And the generation-bump claim is softened to what the code guarantees: a newer refresh had started. It may still fail at its own The self-referential note is fair. A paragraph written to stop over-generalizing, generalizing over a caller set, is the same error one level in. 2. Global capture, local attributionFiltered to 3. The listener leak windowMoved: Gate
|
|
@claude review |
|
Claude finished @cliffhall's task in 3m 54s —— View job Review (round 55): final tree after
|
) `subscribeToResource`'s rollback assumes what its comment says — that the server filter is unchanged — which is exactly what supersession falsifies. The newer refresh built its filter from the set *including* this URI, so on success the server honors it; rolling back leaves the set missing a URI the live stream carries (the UI showing it unsubscribed while its resources/updated keep arriving) and, if it was the only one, an empty set with an active stream — the combination `resetSubscriptionStream` exists to prevent, reached from the other end. Reachable without any close() failure: a rejecting `listen()` propagates the same way, which a user subscribing while the reconnect timer re-lists — i.e. exactly when the server is flaky — can produce. The rollback is now gated on the generation not having advanced past this call's own bump; the error still reaches the caller either way. Test drives two overlapping subscribes with the first `listen()` rejecting, and fails without the gate. The doc bullet that called this the mild site is corrected with it: both user-initiated callers report a failure that did not happen, in mirrored ways. Also gives the two close-failure arms distinct reasons so the unhandled-rejection filter can exclude the sibling arm as well as a foreign rejection. Closes #1797
|
Both taken, in 1. The rollback is the thing that makes that site severeYou're right, and I'm taking the code change rather than only the doc, because the doc claim and the bug are the same mistake: the Your reachability argument is what settles it: no The fix is the generation gate you describe: read Test in the era suite: two overlapping modern subscribes, the first On scope: you're right that the generation check and the rollback both predate this PR. I'm fixing it here anyway rather than leaving a follow-up, since the doc line asserting the site is safe is new in 2. The filter couldn't exclude its siblingFair — both arms threw the same reason, so the mechanism that excludes foreign rejections was blind to the one rejection most likely to arrive late. Each arm now carries its own ( Gate
|
|
@claude review |
|
Claude finished @cliffhall's task in 5m 8s —— View job Review (round 34):
|
…d fails The supersession gate left the both-fail case stuck: two overlapping subscribes whose listens both reject leave the superseded URI in the set (its rollback correctly skipped) while the state keeps the optimistic "connecting" — no stream, no reconnect armed (neither onModernSubscriptionClosed nor onModernReconnectFailed ran), so the badge never changes and no resources/updated ever arrive. That is the caveat this file already wrote down for the neighbouring case — the bump proves a newer refresh *started* — applied to the gate itself. Fixed in the branch that knows the answer: the call that still owns the re-listen reconciles the state instead of only handling an emptied set, reusing the "ended" badge onModernSubscriptionClosed already reports for "stream gone, URIs still subscribed". unsubscribeFromResource gets the same treatment — it rolls nothing back by design, but its failed re-listen left the badge reading Listening over no stream. Also: the gate's comment named one of the two things that bump the generation (resetSubscriptionStream also does, from disconnect and the start-clean reset) and cross-referenced the wrong invariant; both corrected, and `<=` tightened to `===`, which is what the monotonic synchronous bump actually guarantees. Closes #1797
|
All three taken, in 1. The both-fail case — reconciledYou're right, and the trace is exact: the superseded call correctly skips its rollback, the superseding one rolls back only its own URI, and the state is left at the optimistic Taken as you framed it — in the branch that does know the answer. The owning call now calls a small I took your offer to do it once: 2. The comment named one bumper of two, and the wrong invariantBoth corrected.
And the cross-reference was wrong: emptying the set sets 3. Triviata
On the arithmetic coupling: agreed on both halves — it is a cross-method coupling, and the comment stating it is the right minimum here. Changing Gate
|
|
@claude review |
|
Claude finished @cliffhall's task in 6m 57s —— View job Review (round 34): final tree after
|
Two sites emptied the subscription set and announced it before the stream state caught up — the last-URI rollback in subscribeToResource's catch, and (pre-existing) the ordinary last-URI unsubscribe, which dispatches a round-trip before the re-listen sets INACTIVE. Both exposed an empty set with an active stream: the pair resetSubscriptionStream orders its own writes to prevent, and which three comments in this branch now cite. State before the announce at both, asserted from the consumer's side. And the reconcile added last round settled for an honest but dead badge: it was the only route to "stream gone, URIs live" that had made no attempt at all, over subscriptions the server may never have honored, recoverable only by an Unsubscribe/Subscribe toggle (a fresh Subscribe early-returns). It now hands them to the reconnect machinery, which already fits — base delay after a user-initiated refresh, bails on a terminal status or an emptied set, and lands on the same "ended" badge past the cap. That also removes the provably-dead null check the reconcile carried. Closes #1797
|
All three taken, in 1. The catch dispatched through the invariant it citesCorrect, and the placement made it worse than an ordinary ordering slip: the comment fifteen lines above names that exact pair as the harm the gate exists to avoid, and then the rollback announces the emptied set with the state still reading I took the happy-path half too rather than scoping the sentence. You're right that it's pre-existing, but three comments in this branch now lean on that invariant, and "local guarantee" is a weaker thing to be leaning on. Asserted from the consumer's side, as you suggested: a 2. Taken — the reconcile now retriesYour framing settled it: every other route to "stream gone, URIs live" either expects the close or has exhausted the cap, and this was the one that had made no attempt at all — over a URI that, in the both-fail case, no server ever honored, with the user's obvious remedy (click Subscribe again) silently early-returning.
3. The dead guardResolved by construction rather than annotated — with the non-empty branch now calling Gate
|
Closes #1797
Problem
Connecting to
npx -y @modelcontextprotocol/server-filesystem /tmp, the server'sroots/listis answered-32601 Method not found. Connecting toserver-everything, the same request returns the configured roots. Same client, same settings.InspectorClientadvertisescapabilities.roots(and sampling/elicitation) on the SDKClientat construction, so from the momentconnect()sendsnotifications/initializedthe server is entitled to issue those requests. The handlers were registered after the handshake resolved and afterfetchServerInfo()and the optionalsetLoggingLevel()— a window in which the SDKClienthas no handler and replies-32601.server-filesystemasks for roots the instant it is initialized (that is how it learns its allowed directories) and loses the race;server-everythingasks later and wins. The consequence is not just a red entry in the Protocol view —server-filesystemsilently falls back to its CLI-argument directories instead of the roots configured in the Inspector.Fix
Extract the four peer-request handler blocks —
sampling/createMessage,elicitation/create,roots/list, and the receiver-sidetasks/*polls — out of the middle ofconnect()intoregisterPeerRequestHandlers(), and call it beforeclient.connect(this.transport).None of them depend on server capabilities; they read only constructor-set state (
this.roots,this.sample,this.elicit,this.receiverTasks). The notification handlers that do gate onthis.capabilities(tools/resources/promptslist_changed, etc.) stay where they are, afterinitialize.The diff is large but almost entirely the moved block — the behavioural change is the call site plus one dedent level.
Testing
New
clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.tsdrives the race deterministically: a fake transport deliversroots/listsynchronously from inside thesend()ofnotifications/initialized— the earliest instant any server could ask — and asserts the reply carriesresult.roots, not an error. It fails on the pre-fix ordering (expected { id: 9001, … } to not have property "error") and passes with the fix.An HTTP-server integration variant was tried first and rejected: it passed both with and without the fix, because whether the server's request lands before or after the post-connect awaits is network timing. It would not have guarded the regression.
npm run cigreen (twice — the second run against the final file).🤖 Generated with Claude Code
https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr