fix: open the modern listen stream whenever the filter is non-empty (#1920) - #1967
Conversation
…1920) On the modern (2026-07-28) era every server→client notification rides the `subscriptions/listen` stream, but the Inspector only ever opened that stream from a resource-subscribe click. A tools-only server advertising `tools.listChanged` therefore had no path to an open stream, so `notifications/tools/list_changed` could never arrive — even though `buildSubscriptionFilter()` already modelled the opt-in. The trigger now matches the filter it builds: the stream opens (and stays open, and reconnects) whenever the built filter carries anything — subscribed URIs or an enabled list-change opt-in the server advertises. That mirrors the SDK's own `ClientOptions.listChanged` auto-open, which opens on a non-empty config ∩ capability intersection. `connect()` opens it once the handlers are registered; a failure there is handed to the reconnect machinery rather than failing the connect. `ResourceSubscriptionStreamState.active` deliberately keeps its narrower meaning — "at least one URI is subscribed" — because it drives the Subscriptions section's badge, which has nothing to report for a listChanged-only stream. That also preserves the invariant the rest of the file is written against: an empty subscribed set is never announced alongside an `active` stream. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
There was a problem hiding this comment.
Pull request overview
Updates modern subscription streams to open for any non-empty notification filter, including list-change-only servers.
Changes:
- Opens and reconnects modern listen streams based on the effective filter.
- Keeps resource subscription UI state separate from list-change-only streams.
- Adds integration coverage for tools-only servers and stream lifecycle behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
core/mcp/types.ts |
Clarifies stream-state semantics. |
core/mcp/inspectorClient.ts |
Implements filter-driven stream lifecycle. |
clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts |
Tests list-change-only streams and reconnection. |
clients/web/src/components/groups/ResourceControls/ResourceControls.tsx |
Clarifies UI prop documentation. |
Suppressed comments (1)
clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts:372
- This second double cast also needs an inline safety justification. The surrounding comment explains why the method is shadowed, but not why mirroring this private shape is type-safe.
connected as unknown as {
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
Copilot review: `openModernListenStreamOnConnect`'s catch reconciled unconditionally, unlike the subscribe/unsubscribe call sites. The `connect` event is dispatched before the stream opens, so a listener subscribing to a resource can start and acknowledge a newer refresh while this one is still awaiting its `listen()` — and reconciling anyway would arm a reconnect that tears down a healthy stream. Apply the same generation test. Also consolidates the tests' private-member access onto the file's existing `internals()` helper (one justified double cast, hoisted and documented) and makes the failure stub bump the generation the way the real method does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Round 1 review response (eda8ec0)Mirroring the inline replies here, since they go hidden once the fix is pushed. 1. The failure test's stub now bumps the generation synchronously before rejecting (mirroring the real method, which claims its generation before it can fail), so it still reaches the reconcile. New companion test 2. Unjustified double casts in the test — fixed by removing the duplication rather than annotating it twice. The file already had a
|
Copilot review round 2: opening the stream after `dispatchTypedEvent("connect")`
left a real gap. The managed list states start their initial `refresh()` from
that event, so `tools/list` could go out ahead of `subscriptions/listen` — and a
list the server changed in that window would notify nobody, leaving the UI
stale with no way to notice.
The handler registration, the initial logging level, and the stream open now all
run before the `connect` dispatch, so no consumer can act on the connection
until the notification channel is established (or its retry armed). Costs one
listen round-trip on a modern connect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Round 2 review response (d08a530)Stream opened too late to close the notification gap — correct, and fixed. This was a real gap rather than a nicety: the managed list states start their initial The handler registration, the initial New test Full local gate green: |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (3)
core/mcp/inspectorClient.ts:4997
connectis now dispatched only after this method returns (line 2076), so the stated race through aconnectlistener cannot occur. Keep the generation guard, but describe an actual superseding path such as code reacting to the lifecycle events emitted before this call or another concurrent public call; otherwise this comment contradicts the ordering this PR intentionally establishes.
* Gated on the same generation test as `subscribeToResource` — see the long
* comment there. This call is *not* the only refresh that can be in flight:
* the `connect` event has already been dispatched, so a listener subscribing
* to a resource can start and acknowledge a newer refresh while this one is
* still awaiting its `listen()`. Reconciling unconditionally would then arm a
* reconnect that tears down a stream that is up and healthy.
core/mcp/inspectorClient.ts:5172
- The JSDoc above still defines these branches as “nothing subscribed” versus “URIs live” and says the timer bails when the set empties. This predicate now intentionally stays true with an empty URI set when a list-change opt-in remains, so document the empty/non-empty filter instead; the current description is false for the new listChanged-only path.
private reconcileModernStreamStateAfterFailedRefresh(): void {
if (!this.wantsModernStream()) {
clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts:438
- This scenario says the
connectevent fires before the stream open, but the production code now deliberately dispatches it afterward. Update the explanation to the lifecycle events that are actually emitted earlier; the current comment directly contradicts the preceding ordering test.
it("leaves a superseded connect-time failure to the refresh that owns the stream", async () => {
// `connect` is dispatched before the stream is opened, so a listener can
// start and acknowledge a newer refresh while this one is still awaiting
// its `listen()`. Reconciling anyway would arm a reconnect against a
// healthy stream and tear it down — the same ownership test the
// subscribe/unsubscribe paths make.
Copilot review round 3 (no new code findings, three stale-comment ones): - `openModernListenStreamOnConnect`'s generation guard was justified by a `connect` listener racing it — which the previous commit made impossible by dispatching `connect` after this call. The guard is still right, for reasons that survive the reorder: `statusChange` has already fired, and a concurrent `subscribeToResource` or `disconnect()` (whose `resetSubscriptionStream` bumps the generation) can supersede it. - `reconcileModernStreamStateAfterFailedRefresh`'s JSDoc still described its branches as "nothing subscribed" vs "URIs live"; they are the empty vs non-empty *filter* now, which differ exactly on the listChanged-only path. - The superseded-failure test carried the same stale `connect` rationale. Comments only; no behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Round 3 review response (b2dfd40)No new code findings. The three suppressed comments were all correct — the round-2 reorder invalidated comments written in round 1 — and are fixed. Comments only, no behavior change.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
core/mcp/inspectorClient.ts:2076
- The new awaited listen open creates a window where
disconnect()(including one still awaitingclient.close()) or a transport close can overtake this connect, but this line still emitsconnectunconditionally afterward. That re-triggers every managed list refresh against a session being torn down or already dead. Only announce the connection if it is still connected and no explicit disconnect owns teardown.
this.dispatchTypedEvent("connect");
…m open Copilot review round 4: every await before the `connect` dispatch is a window for a `disconnect()` or a transport close to overtake the connect, and the listen round-trip widened it. Announcing anyway restarts every managed list refresh against a session being torn down or already dead. The dispatch is now gated on `status === "connected" && !disconnecting` — the second because an explicit teardown claims ownership before it awaits `client.close()`, leaving the status untouched until that block finishes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Round 4 review response (2a4e93c)No new inline comments; the one suppressed comment was right and is fixed. The The dispatch is now gated on New test
|
Closes #1920
The gap
On the modern (2026-07-28) era every server→client notification rides the
subscriptions/listenstream. The Inspector only ever opened that stream from a resource-subscribe click:…while the filter it builds already modelled the list-change opt-ins (
toolsListChanged, etc.). So a tools-only server that correctly advertisestools.listChangedhad no path to an open stream —notifications/tools/list_changedwas unreachable from the UI, because the Resources screen (the only affordance that opened it) isn't there when the server has no resources.The fix
Option 1 from the issue thread: the trigger now matches the filter it builds. The stream opens — and stays open, and reconnects — whenever
buildSubscriptionFilter()returns anything: subscribed URIs or an enabled list-change opt-in the server advertises. That is the same rule the SDK's ownClientOptions.listChangedauto-open uses (non-empty config ∩ capability intersection), applied to the hand-managed stream the Inspector keeps for its re-list/reconnect semantics.wantsModernStream()replacessubscribedResources.size === 0at every "should the stream be open" site: the refresh bail, the reconnect decision after a drop, the reconnect timer's bail, the give-up check, and the post-failure reconcile.connect()opens the stream once the notification handlers are registered. A failure there does not fail the connect — the handshake succeeded and every request-scoped feature works without the stream — it is handed to the existing reconnect machinery, which retries with backoff and settles onendedpast the cap.buildSubscriptionFilter()now omitsresourceSubscriptionswhen nothing is subscribed, rather than sending[].What
activestill meansResourceSubscriptionStreamState.activedeliberately keeps its narrower meaning — at least one URI is subscribed — via a separatemodernStreamActive(). It drives the Subscriptions section's badge, which has nothing to report for a stream open purely for list-change notifications. Keeping the two predicates apart also preserves the invariant the rest ofinspectorClient.tsis written against: an empty subscribed set is never announced alongside anactivestream.Tests
New
modern era: opening the stream for listChanged alone (#1920)block ininspectorClient-subscriptions-era.test.ts, against a real tools-only server over a real transport:toolsListChanged, noresourceSubscriptions; the Subscriptions state stays inactive)Plus a new
keeps the stream open past the last subscription when a listChanged opt-in remains, the counterpart to the existing "closes the stream when the last subscription is removed".Four existing lifecycle tests needed an empty filter to keep testing what they were written to test. The SDK server advertises
listChangedfor every list it registers, so that can't come from the server config — those tests now connect with every Inspector-side opt-in off (NO_LIST_CHANGED), which is the pre-#1920 world they assume.No UI change
Nothing visible changed: the Subscriptions badge is gated on the filtered subscription count, so a listChanged-only stream shows no chrome (and
activestaysfalsefor it). The only file touched underclients/web/srcis a doc comment on thesubscriptionStreamStateprop. Hence no screenshots.Gate
npm run ci—validate,verify:build-gate,smoke, and Storybook all pass;core/mcp/inspectorClient.tscovers 96.7 / 91.9 / 95.4 / 97.1 (stmts/branch/funcs/lines).One caveat, reproduced on
v2/mainunchanged: thecoveragestep exits 1 locally on two pre-existingConnection closedunhandled rejections frominspectorClient.test.ts(disconnect()during the progress/timeout tests). Identical exit code and identical failing tests with this branch stashed, so it is not from this change. All 4887 tests pass.