fix(core): register peer-request handlers before connect (#1797)#1798
fix(core): register peer-request handlers before connect (#1797)#1798cliffhall wants to merge 55 commits into
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 review |
|
Claude finished @cliffhall's task in 6m 41s —— View job Review (round 47):
|
| member | resetSessionState |
disconnect() |
|---|---|---|
| receiver tasks | clearReceiverTasks() :1481 |
clearReceiverTasks() :1965 |
| subscription stream | resetSubscriptionStream() :1482 |
resetSubscriptionStream() :1947 |
cancelledTaskIds |
.clear() :1483 |
.clear() :1948 |
taskInputAbortControllers |
abort + clear :1484-1488 |
abort + clear :1957-1961 |
modernLogLevel |
resolveModernLogLevel(…) :1492 |
= undefined :1980 |
So it's 2 via helpers and three hand-rolled, not two — and "sixth" is right, which is what makes the mismatch visible: 2 + 2 ≠ 5.
The sharper half is which member the enumeration drops. modernLogLevel is the only one where the two places deliberately do different things — one re-derives from the settings, the other blanks — and disconnect()'s own comment four lines above it says so outright: "This is no longer what stops it leaking into the next connection … removing this would leak nothing." So "clears all of this on the way out too" is true in the loose sense of "touches all of it", but for that member the duplication story the sentence is telling doesn't apply, and the doc-vs-doc disagreement is the thing a reader trips on.
The actionable advice ("a sixth member added here has to be added there as well") is correct and worth keeping; it's the arithmetic supporting it that's off, plus a clause for the member that's paired rather than duplicated.
2. The new close makes subscription.closed settle — and that chain has no rejection handler
core/mcp/inspectorClient.ts:4708-4710:
void subscription.closed.then((reason) =>
this.onModernSubscriptionClosed(subscription, reason, generation),
);Every other promise around this stream carries a .catch — previous.close().catch(() => {}) (:4675), the superseded subscription.close().catch(() => {}) (:4693), and the new closing?.close().catch(() => {}) (:1448). This one doesn't, so if closed ever rejects the derived promise rejects unhandled.
What makes it worth raising in this commit rather than as pre-existing noise is that the commit's own reasoning is "closed never resolves for a live stream" — i.e. before this change, the connect-path reference was dropped with its closed permanently pending, so the chain never settled at all. Closing it is precisely what makes it settle, on a path that previously couldn't. Resolve is the normal outcome; reject is the one with no handler.
Severity is low and bounded — a browser console error today, since only web sets receiverTasks/subscribes. But rounds 25–26 established that in Node an unhandled rejection terminates the process by default, and void payloadPromise.catch(() => {}) is the fix this file already uses for exactly this shape.
Caveat: this checkout has no
node_modules, so I could not check whetherMcpSubscription.closedcan reject rather than always resolving with a reason. If it can't, nothing fires — but the one-line.catchcosts nothing and makes the answer not depend on an SDK internal.
3. Two triviata
- The new test exercises transport reuse but doesn't assert it. Reuse is the half that makes "a stream still open on the server" true, and it holds only because
isHttpOAuthConfig()is false for the stdio config, so thedropCachedTransportbranch at:1564-1573is skipped. A future change to that branch could recreate the transport and the test would stay green while no longer testing the scenario its comment describes. Since the factory hands back the sametransportobject every call, pinning it needs a call counter —let created = 0; transport: () => { created++; return { transport }; }andexpect(created).toBe(1). Cheap, and it makes the premise fail loudly rather than silently stop applying. this.receiverTasksis now a single-use field — read only at its assignment (:514) and the capability advertisement (:637), with everything downstream ontasksCapabilityAdvertised. That's round 38 landing completely. It'sreadonly, so it can't drift; noting it only so the remaining read reads as "the advertisement's input" rather than a survivor.
4. Also swept, nothing to raise
resolveModernLogLevel's two callers get the same input in practice. The web re-seed readsserversRef.current.find(…).settingswhile the client readsthis.serverSettings— different objects, but the settings-modal close pushes the draft onto the live client for the active server (App.tsx:3993) and persists through the sameupdateServerSettings, so for the connected server they agree. UsinginspectorClient.getServerSettings()in the re-seed would make it literally one input as well as one derivation, but that's a refinement, not a gap.- The
activeServerIdRefdependency in the re-seed is fragile but documented — the comment names the passive-effect sync that makes it still hold the outgoing id duringonDisconnect, and says what breaks if that ref is cleared eagerly. That's the right place for it. - The crash path deliberately leaves the subscribed set and stream state in place until the next
connect(), and the reworded comment at:4737-4741now says so and explains why "active with an empty set" is still never observable. Accurate. resetSessionState()runs before thedropCachedTransportbranch, so in the HTTP+OAuth case the stream is closed and then the transport dropped — harmless ordering, and the close is best-effort either way.resetSessionState()correctly doesn't touch the raw-wire map, the peer queues, or the in-flight tool call, and the doc now names which path owns each —callTool's ownfinallyfor the tool call,disconnect()and the crash path for the rest. Matches the code.- Coverage. The new test adds no production lines; the new close is exercised by it, and the
closing == nullarm by every other connect in the suite.
On the shape of this round
Both items are consequences of the fix rather than of the diff it replaced: the doc's enumeration went stale as the fifth member acquired a deliberately-asymmetric pair, and the promise chain acquired a settle path it never had. Neither is visible from the commit alone.
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 tree and diffingorigin/v2/main...HEADand64bf2b99..HEAD.
· branchv2/fix-roots-handler-race
#1797) - resetSessionState's doc said "two members through the same helpers, the other two hand-rolled" for a body with five. It is two and three — and the member the enumeration dropped is the one that is *paired* rather than duplicated: modernLogLevel is re-derived here and blanked in disconnect(), deliberately. Both corrected. - `subscription.closed.then(...)` had no rejection handler, unlike every other promise around that stream. Newly reachable: before the last commit a connect-path reset dropped the reference with `closed` permanently pending, so the chain never settled at all; closing the stream is what makes it settle. Added, with the Node-terminates reasoning the file already applies to payloadPromise. - The listen-stream test exercised transport reuse without asserting it — the half that makes "still open on the server" true. Counts factory calls now, so the premise fails loudly if a future change recreates the transport rather than silently ceasing to apply. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr
|
All three taken, in 1. The count — and the member it droppedRight on both halves, and the second is the substantive one. I wrote "two and two" for a five-member body, and the member the arithmetic dropped was 2. The unhandled
|
|
@claude review |
|
Claude finished @cliffhall's task in 8m 3s —— View job Review (round 48): final tree after
|
…1797) - The .catch comment claimed the connect-path close made a *rejecting* `closed` reachable. It doesn't: closing resolves it. What that close newly reaches is the handler running at all, where the reference used to be dropped with `closed` pending forever. And chaining after `.then` also swallowed handler throws — silently abandoning a re-listen on the path whose job is recovering a dropped stream. Now the second argument to `.then`, scoped to the rejection, with the comment saying which half is which. - `closing?.close()` reaches into third-party code from `disconnect()`'s straight-line teardown, none of which is inside a `try` — a synchronous throw would skip the raw-wire rejects, the task aborts, the receiver-task clear and nine dispatches, silently, since most callers catch a disconnect(). Best-effort now against both failure modes. - Header indexes the listen-stream test: same category, but a release obligation rather than a misread one. - Its comment described the generation guard, which the stub can't exercise; marked as production behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr
|
All four taken, in 1. The catch named the wrong half — and hid the right oneYou're right on both counts, and the second is the one that mattered: closing a stream resolves And because I chained the 2. The synchronous-throw exposure — guardedGood catch that Noted your caveat that this is about what a third-party 3–4. Header and test comment
On your truncation noteUnderstood, and nothing you raised was a repeat — all four were live. For context, the rounds you can't see covered the same enumeration going one level out (teardown → what a new session inherits), which is what produced Gate
|
|
@claude review |
|
Claude finished @cliffhall's task in 7m 3s —— View job Review (round 49): final tree after
|
Round 48 made `resetSubscriptionStream`'s close best-effort against both a rejected promise and a synchronous throw, but nothing exercised either arm: both `modernSubscription` stubs in the tree resolve. On the `disconnect()` path an escaping failure would silently skip the raw-wire rejects, the paused task-input aborts, the tool-call abort and `clearReceiverTasks()` — the findings rounds 13 and 30 closed, re-opened at once with a green suite. Two table-driven cases now seed a `close()` that throws and one that rejects, and assert teardown continued past it. Also: the `.then` comment gave opposite verdicts on one mechanism (an unhandled rejection is named unacceptable, then chosen for a handler throw) — reworded to say the handler cannot throw today and why the shape is defensive; and `refreshModernSubscription`'s doc block, orphaned two methods up since before this branch, moved onto the method it describes. Closes #1797
|
All three taken, plus the triviata, in 1. The
|
|
@claude review |
|
Claude finished @cliffhall's task in 8m 9s —— View job Review (round 50): final tree after
|
| collection | cleared on the onerror-only route? |
|---|---|
the five resetSessionState members |
✅ :1571 |
pendingSamples / pendingElicitations |
❌ only :793, :1876, disconnect() |
pendingRawWireRequests |
❌ only :801, :1965 |
This is not a premise I'm introducing — it's the one 303c3924 was accepted on, and the test header states it as fact at line 79: "a connect() reusing a transport an onerror left up can be holding the last reference to a live stream." Four lines above, the same paragraph says "the peer-request queue is cleared end-clean on all three teardown paths" (:45-49 enumerates them). Both sentences can't be load-bearing at once: if the route is real enough to justify closing the stream, it's real enough to strand a queued elicitation.
The peer-queue consequence is the sharper of the two, and it's worse than the stream's. App.tsx:4020 derives the pending-request modal from the queue lengths with no status gate, so the modal survives into the new session — and answering it now resolves the SDK request handler against a transport that is live, writing a response for session 1's request id onto session 2's connection. The raw-wire half is milder: an id that can't collide, rejecting on its own 30 s timer and blaming a timeout for a crash, which is exactly what round 30 fixed for onclose.
Both calls are already idempotent — clearAndAnnouncePendingPeerRequests guards on a non-empty queue and rejectPendingRawWireRequests clears its map — so adding them beside resetSessionState() costs nothing on the routes that already run them, and anything pending at the top of connect() necessarily belongs to a session that is no longer connected. The doc's route-based justification is the part that needs the edit as much as the code: "handled by the teardown paths" is only true of the paths that run.
2. Two sibling close() sites lack the synchronous-throw guard round 48 added (nit)
resetSubscriptionStream now wraps its close in a try because "a synchronous throw from a third-party close() … would otherwise abort the caller's remaining teardown" (:1451-1456). refreshModernSubscription has the other two, both bare:
:4686 await previous.close().catch(() => {}); // after modernSubscription = null
:4702 await subscription.close().catch(() => {}); // superseded-generation discard:4686 is the one worth the line. this.modernSubscription = null runs first, so a synchronous throw both drops the last reference to a live stream — the round-46 finding, at the sibling site — and aborts the refresh before the replacement is opened, leaving subscribedResources non-empty with no stream. All three callers catch (:4807, :4869, :4913), so it surfaces as "Failed to unsubscribe from resource" rather than escaping; that's why it's a nit and not finding 1. But it's the same third-party close() and the same failure mode, and this file has spent twenty rounds on "both sites read the same rule."
3. Also checked, nothing to raise
- The web genuinely rebuilds the client on a user-initiated reconnect (
App.tsx:2629,setupClientForServer), so finding 1's route is reached fromconnect()-on-the-same-instance only — the same narrow set of callers that makesresetSessionState()itself largely defensive. Both are the right kind of defensive; I'm flagging the asymmetry between them, not the caution. resetSubscriptionStream()announces unconditionally whereclearAndAnnouncePendingPeerRequestsguards on a non-empty queue, so a firstconnect()emits two no-op events. Nothing indisconnect()'s batch re-emits them, so there's no double-dispatch — just two inert renders. Not worth the guard.resetSessionState()runs beforedropCachedTransport()inconnect(), so that drop'sonclosecan't repopulate anything the reset just cleared, and its own clears are no-ops on the guard.resolveModernLogLevel's narrowing is sound —level === "off" ? undefined : levelnarrowsModernLogLeveltoLoggingLevel, so the declared return type needs no cast — andmodernLogLevel.test.tscovers all three arms including theundefined-settings one.- The crash-then-reconnect log-level window leaves the client holding a mid-session override while the web control shows the configured level (the crash path blanks neither). Nothing is sent in that window and the next
connect()reconciles both, which is the same trade thedisconnect()comment at:1984-1989already documents. - Coverage. Both arms of the reset's
closing?.are exercised (the seeded-stream tests and every other test's null), bothclose()failure modes by the table, and the five session members each have a test —taskInputAbortControllersgained the one round 42 noted it lacked.
On fifty rounds
The last sixteen were a category the earlier ones didn't have: not "did we advertise what we serve" or "did we answer what we accepted", but "does the next session start clean" — and it produced five real members plus a stream that could be leaked unclosable. Finding 1 is that enumeration checked against its own stated route rather than against the two paths it names, which is the same site → axis → enforcement move one level out. The code has been converged since 303c3924; this is about which routes the invariant claims to cover.
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...HEADand64bf2b99..HEAD.
· branchv2/fix-roots-handler-race
`resetSessionState`'s doc excluded the peer-request queues and the raw-wire map because "the teardown paths handle them" — true of the paths that run. An `onerror` without an `onclose` runs none of them: it flips status to "error" and leaves `baseTransport` cached, so a `connect()` on the same instance reuses a live transport. That is the route the round-46 stream close was built on, and it strands these two the same way — the web pending-request modal is derived from the queue length with no status gate, so it outlives the session, and answering it writes a response for the old request id onto the new connection. Both are now swept start-clean beside the reset (both helpers are idempotent, so the routes that already ran them are unaffected), the end-clean clears stay put for the `disconnect`-event consumer, and both docs say which is which. Also folds the three subscription `close()` sites onto one `closeSubscriptionBestEffort` helper, so all three absorb a synchronous throw as well as a rejection — at `refreshModernSubscription`'s first site the reference is already dropped, so a throw there both leaks a live stream and aborts the refresh before its replacement is opened. Closes #1797
|
Both taken, in 1. The route's other two collections — sweptYou're right, and the contradiction you point at is the sharpest form of it: the test header states the Confirmed on the tree: Both are now swept beside the reset at the top of The end-clean clears stay exactly where they are, and that's the part worth stating rather than leaving implicit: a Two tests, on the route rather than near it: an elicitation queued, 2. Three close sites, one ruleFolded onto a module-level Gate
|
|
@claude review |
|
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
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