Resources: subscriptions/listen model on modern servers (era fork) (#1630)#1730
Conversation
…1630) On modern (2026-07-28) servers `resources/subscribe`/`unsubscribe` are gone; subscriptions are a filter over one long-lived `subscriptions/listen` stream (SEP §7.4). Fork resource subscriptions by era while keeping the legacy path byte-identical. Core (`InspectorClient`): - `subscribeToResource`/`unsubscribeFromResource` fork on the negotiated era: legacy still sends `resources/subscribe`; modern owns a single `McpSubscription` whose filter's `resourceSubscriptions` mirrors the subscribed-URI set, folding in the listChanged opt-ins so one stream carries every opted-in type. Mutating the set re-lists (close old, open new); the last unsubscribe closes it. - Reconnect-by-re-listen on an unexpected `"remote"` drop (no resumability); a `modernListenGeneration` guard drops stale re-lists/reconnects; the stream is torn down and reset on disconnect. - `getResourceSubscriptionStreamState()` + `resourceSubscriptionStreamChange` event expose acknowledged/reconnecting/ended lifecycle. - `notifications/resources/updated` is era-transparent (same handler both eras), so the receive path is unchanged. State + hook: `ResourceSubscriptionsState` tracks the stream state and `useResourceSubscriptions` returns it. Web UI: the Subscriptions section shows a stream-status badge atop its panel and a status dot in its accordion header (tooltip-only) — modern era only; legacy is unchanged. New `SubscriptionStreamBadge` element. `subscriptionStreamState` / `protocolEra` threaded App → InspectorView → ResourcesScreen → ResourceControls. Tests: unit coverage for the store/hook stream state, the badge, and the ResourceControls era gating; integration coverage proving `subscriptions/listen` (not `resources/subscribe`) on a real modern server, legacy unchanged, re-list, filter contents, and the reconnect/error branches. `npm run ci` green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
… screenshots (#1630) Add two manual-testing configs and README docs for the resource-subscription era fork, plus end-to-end proof screenshots captured against both real servers: - subscriptions-legacy-http.json: streamable-HTTP server with `subscriptions: true`, three numbered resources, and an `update_resource` tool. Subscribe sends `resources/subscribe` (no stream chrome); `update_resource` emits `notifications/resources/updated`, stamping the subscribed tile. - subscriptions-modern-http.json: same with `transport.modern: true`. Connect with Protocol Era = Modern and Subscribe sends `subscriptions/listen` (filter carries resourceSubscriptions + resourcesListChanged), resolves on `notifications/subscriptions/acknowledged`, and shows the stream badge + header dot; a dropped stream reconnects by re-listing. pr-screenshots/ documents the verified behavior: modern listen+acknowledged, the modern badge/dot with reconnect-by-re-listen, legacy `resources/subscribe` with no chrome, and the legacy live-update round-trip. README notes the caveat that a tool-triggered `resources/updated` doesn't reach the modern listen stream on the SDK's stateless leg (separate connections), so the live update is shown on legacy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
…op per-entry era badge (#1630) Address review feedback on the modern subscription UI: - The stream-status badge now lives in the **Subscriptions accordion header** (next to the count, visible while collapsed) instead of a separate row above the list plus a bare header dot. The labels (`Listening` / `Reconnecting…` / `Stream ended`) all fit, so `SubscriptionStreamBadge` drops its `dot` variant and always renders the labelled badge. - Remove the per-frame **"modern" badge** from `ProtocolEntry` (both render sites). The connection era is already shown once in the Protocol panel header, so the per-entry badge was redundant and cramped the sidebar. Also drops the now-unused `isModernFrame` / `MODERN_METHODS` from `protocolUtils` and their tests. Tests/stories updated accordingly; `npm run ci` green. pr-screenshots refreshed: subscriptions-modern-header-badge.png (the header badge) replaces the old panel badge shot, and subscriptions-modern-listen-acknowledged.png now shows the Protocol view with no per-entry era badge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
#1630) In the narrow monitoring-sidebar (embedded) layout, the `sub … listen-id` subscription-id cluster shared the second line with the method badge, crowding the pin control and truncating long notification method names. Move the cluster to the top line's trailing edge (a notification row's duration/status slots are empty there), so the method badge on the line below gets the full column width. Adds an EmbeddedSubscriptionNotification story for the compact layout and an embedded-layout unit assertion; refreshes the Protocol screenshot. `npm run ci` green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
@claude review |
|
Claude finished @cliffhall's task in 3m 49s —— View job Review — Resources:
|
…on map (#1630) Address review feedback on the modern subscription era fork: - Reconnect-by-re-listen now uses capped exponential backoff (500ms base, ×2, capped at 15s) with a 30s reset window and a bound on consecutive rapid reconnects — past the cap it marks the stream ended instead of spinning a tight zero-delay loop. The reconnect timer is cleared/reset on disconnect and on user-initiated (subscribe/unsubscribe) refreshes. - Skip the re-listen on no-op mutations: re-subscribing an already-subscribed URI and unsubscribing a URI that isn't subscribed leave the filter unchanged, so they no longer churn the server stream. - `subscriptionStreamPresentation` is now a `Record` keyed by status — exhaustive at compile time (a new status is a type error) with no unreachable branch. Tests: fake-timer coverage for the backoff (give-up, reset window, timer-bail) and the no-op skips. `npm run ci` green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Thanks for the thorough review! Responses below — fixes are in 349e5a1. 1. Reconnect backoff — fixed. 2. 3. Modern list-changed coupled to ≥1 resource subscription — intended for this card's scope. #1630 is specifically resource subscriptions, and the issue models the filter with the listChanged flags, so folding them in means the one open stream also carries list-changed (the spec's unified-stream model). You're right that on modern, list-changed only flows while a resource is subscribed, and a mid-session listChanged-opt-in toggle doesn't re-list. Full modern list-changed delivery independent of resource subscriptions is a broader modern-notifications concern that belongs in a separate card in the stack, not here — flagging it there. 4. Redundant re-list on no-op mutations — fixed. Re-subscribing an already-subscribed URI and unsubscribing an unknown URI now early-return (filter unchanged → no re-listen). Tests assert no 5. 6. Stale PR description — fixed. Updated the body: the showcase configs + screenshots are in this PR (not deferred), and the UI is described as the single labelled badge in the accordion header. Nit (exhaustiveness) — fixed.
|
|
@claude review |
|
Claude finished @cliffhall's task in 2m 1s —— View job Re-review —
|
…ose real streams in internals tests (#1630) - `onModernSubscriptionClosed`'s graceful/local branch now sets `active: subscribedResources.size > 0` (matching the reconnect give-up branch), so a graceful listen-stream close while URIs are still subscribed keeps the "Stream ended" badge visible instead of silently hiding it. - Test hygiene: the internals tests that drive `onModernSubscriptionClosed` by hand now swap the real listen stream for a controllable fake (closing the real one first) via `installFakeSubscription`, so they no longer leave a live stream that rejects "Connection closed" on teardown. Added graceful-close coverage (subs-remain → active, subs-cleared → inactive). `npm run ci` green (no unhandled rejections). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Thanks — addressed the badge-consistency note in e7ba4fc. Graceful-close badge parity — fixed. While there, a bit of test hygiene: the internals tests that drive
|
|
@claude review |
|
Claude finished @cliffhall's task in 2m 13s —— View job Re-review —
|
… failures (#1630) Adopt the standard reconnect semantics: the backoff/give-up now counts only consecutive *failed* re-lists, not drops of streams that reconnect and hold. - `refreshModernSubscription` resets `modernReconnectAttempts = 0` on a successful acknowledgement. - A `"remote"` drop schedules a re-listen at the current backoff (base delay after a healthy stream). A re-listen that *fails* increments the count and retries with a longer backoff; past the cap it gives up (ended). So a server that reconnects-and-holds but flaps every ~20s reconnects indefinitely (paced), while a persistently-failing re-list is bounded. - Split the reconnect loop into `scheduleModernReconnect` / `onModernReconnectFailed` and dropped the now-unused drop-to-drop reset window (`modernLastReconnectMs`, `MODERN_RECONNECT_RESET_MS`). Tests rewritten for the new semantics (retry-then-give-up on repeated failures, reset-on-successful-ack). `npm run ci` green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Good call — implemented the reset-on-success semantics in 48c21de. The backoff/give-up now counts only consecutive failed re-lists, not drops of streams that reconnect and hold:
Tests rewritten for the new semantics: retry-then-give-up on repeated re-list failures, and reset-of-the-count after a successful ack. |
|
@claude review |
|
Claude finished @cliffhall's task in 2m 6s —— View job Re-review —
|
…up case (#1630) `ended` is both a graceful server close and the client giving up after repeated failed reconnects, so the tooltip no longer attributes it solely to the server: "…the server ended it (for example, on shutdown) or reconnection was abandoned after repeated failures. Re-subscribe to try again." Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Thanks! Reconnect tradeoff — deliberate, keeping as-is. Confirming this is a conscious choice, not an oversight. After going back and forth between drop-based and failure-based give-up, I'm settling on: reset the backoff on a successful ack and bound only consecutive failed re-lists. The flip side you noted — an ack-then-immediate-drop server reconnecting forever — is throttled at the 500 ms floor (no tight loop, no CPU burn), and I think perpetually retrying a server that is acknowledging is the right behavior: it's genuinely reachable, just unstable, and the alternative (giving up on a server that keeps saying "yes") is worse. Counting time-in-healthy-stream to also cap that pattern is a reasonable enhancement, but it adds state for a pathological edge, so I'm leaving it out for now. Tooltip nit — fixed (a57cbeb). The
|
|
@claude review |
|
Claude finished @cliffhall's task in 1m 13s —— View job Re-review —
|
…ubscriptions (#1630) The Subscriptions accordion control disables at `filteredSubscriptions.length === 0`, but the stream badge was gated only on `subscriptionStreamState.active` (the real set), so a search matching none of the live subscriptions left a "Listening" badge next to a disabled "Subscriptions (0)" header. Gate `streamStatus` on the filtered count too, so the badge hides with the emptied section. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Thanks — nice catch, fixed in 630a9a4. Search vs. badge mismatch — fixed.
|
|
@claude review |
|
Claude finished @cliffhall's task in 59s —— View job Re-review —
|
|
Thanks for the thorough multi-round review — no further changes on my end. Ready for merge whenever. |
…optimistic "connecting" UI (#1630) Two fixes so modern resource subscriptions are responsive in the web client. Root cause — `listen()` hung ~60s over the browser↔backend proxy: the SDK's `listen()` does `await transport.send(...)` before the ack, and both the browser remote transport (`requestIdForMessage`) and the backend send route (`requestIdForSendWait`) held the send waiting for a JSON-RPC *response*. `subscriptions/listen` is a long-lived stream that never sends one, so the send blocked the full timeout, then its rejection resolved the stream's `closed` as `"remote"` and drove the reconnect loop (Connecting → brief ack → Reconnecting → …). Exempt `subscriptions/listen` from the response-wait on both sides — its ack and stream notifications already arrive over the SSE event channel. UX — optimistic subscribe: on the modern era `subscribeToResource` now reflects the subscription and a new `"connecting"` stream status immediately (rolling back on failure), so the click gets instant feedback (sidebar entry + Unsubscribe button + "Connecting…" badge) instead of a dead window until the ack round-trips. Adds `connecting` to `ResourceSubscriptionStreamStatus` + the badge presentation. Tests: optimistic pre-ack `connecting` state; browser + backend transport exemptions for `subscriptions/listen`; badge/story for the new status. `npm run ci` green; verified end-to-end in the browser against the modern server. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
…ase (#1630) The SDK's modern leg is stateless/per-request (`createMcpHandler(() => createMcpServer(config))`), so `update_resource` runs against a throwaway server instance: the content change doesn't persist for the next `resources/read`, and its `resources/updated` can't reach the (separate) listen stream. Having the tool there is more confusing than useful — it looks like a no-op. Remove it from the modern config (it stays on the legacy, stateful-session config, where the live update round-trip works). README updated to explain the split, and to note the browser modern-subscribe timing fix (no more ~60s hang / reconnect churn). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
Closes #1630
Stack: SDK V2 + New Spec — 7 of 10.
Forks resource subscriptions by protocol era. On modern (2026-07-28) servers
resources/subscribe/unsubscribeare gone — subscriptions are a filter over one long-livedsubscriptions/listenstream (SEP §7.4). Legacy stays byte-identical.Core (
InspectorClient)subscribeToResource/unsubscribeFromResourcefork on the negotiated era:resources/subscribeper URI.McpSubscriptionwhose filter'sresourceSubscriptionsmirrors the subscribed-URI set, folding in the listChanged opt-ins (config ∩ capability) so one stream carries every opted-in type. Mutating the set re-lists (close old, open new); a no-op mutation (re-subscribe / unsubscribe of an unknown URI) skips the re-list; the last unsubscribe closes the stream."remote"drop (the stream isn't resumable), with capped exponential backoff and a bound on consecutive rapid reconnects (past the cap it marks the stream ended rather than looping). AmodernListenGenerationguard drops stale re-lists/reconnects; the stream + reconnect timer are torn down and reset on disconnect.getResourceSubscriptionStreamState()+ aresourceSubscriptionStreamChangeevent expose the acknowledged / reconnecting / ended lifecycle.notifications/resources/updatedis era-transparent (delivered through the same handler on both eras), so the notification-receive path and theresources.subscribesupport gate are unchanged.State + hook
ResourceSubscriptionsStatetracks the stream state (seed +streamStateChange);useResourceSubscriptionsreturns{ subscriptions, streamState }.Web UI
The Subscriptions section gains modern-only chrome (legacy unchanged): a single labelled stream-status badge (
Listening/Reconnecting…/Stream ended, tooltip-explained) in the accordion header, next to the count, so it stays visible while the section is collapsed. NewSubscriptionStreamBadgeelement.subscriptionStreamState/protocolErathreaded App → InspectorView → ResourcesScreen → ResourceControls. Also: the per-frame "modern" badge is dropped fromProtocolEntry(the era is shown once in the panel header), and thesub … listen-idtag rides the compact entry's top line so long method badges don't truncate against the pin.Tests
subscriptions/listen(notresources/subscribe) on modern, legacy unchanged, re-list on partial unsubscribe, no-op skips, filter contents (URIs + listChanged), and the reconnect / backoff give-up / error-rollback branches.npm run cigreen (validate → coverage ≥90 per-file → smoke → storybook).Showcase
Manual test-server configs (
subscriptions-{legacy,modern}-http.json) + README docs +pr-screenshots/are included. Caveat: a tool-triggeredresources/updateddoesn't reach the modernsubscriptions/listenstream on the SDK's stateless modern leg (the tool call and the listen stream are separate connections), so the live-update round-trip is demonstrated on the legacy server; the modernlistenpath (send + acknowledged + reconnect) is proven both in the integration suite and the screenshots.Screenshots
Modern era — the stream-status badge in the Subscriptions header (
Connecting…→Listening):Modern era — Protocol view:
subscriptions/listen(PENDING) +notifications/subscriptions/acknowledged, no per-entry era badge,sub … listen:0on the top line:Legacy era —
resources/subscribe, no stream chrome:Legacy era — live update:
update_resource→notifications/resources/updatedstamps the subscribed tile:🤖 Generated with Claude Code
https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5