Skip to content

Expose live resource subscriptions via a hook (#1325)#1326

Merged
cliffhall merged 9 commits into
v2/mainfrom
v2/issue-1325-resource-subscriptions-hook
May 20, 2026
Merged

Expose live resource subscriptions via a hook (#1325)#1326
cliffhall merged 9 commits into
v2/mainfrom
v2/issue-1325-resource-subscriptions-hook

Conversation

@cliffhall
Copy link
Copy Markdown
Member

Summary

  • Add ResourceSubscriptionsState (core/mcp/state/resourceSubscriptionsState.ts): subscribes to the InspectorClient's resourceSubscriptionsChange (URI list) and resourceUpdated events, resolves each URI against the optional ManagedResourcesState so subscriptions carry the server-supplied Resource (name/title), stamps lastUpdated on notifications/resources/updated, and clears on disconnect.
  • Add useResourceSubscriptions (core/react/useResourceSubscriptions.ts): subscribes to the state manager and returns the live InspectorResourceSubscription[].
  • Wire App.tsx to construct/destroy the state manager alongside the other managed stores and pass the hook's output to InspectorView (replacing the subscriptions={[]} fallback added in Wire App.tsx to v2 core/ hook layer; remove demo stub from InspectorView #1244).

Closes #1325.

Test plan

  • npm run validate (format, lint, build, unit + coverage gate)
  • npm run test:integration (379 passed)
  • npm run test:storybook (300 passed)
  • New files at 100/100/100/100 coverage
  • Manual: connect to the everything server, subscribe to a resource, verify it appears under "Subscriptions"; unsubscribe and verify it disappears; trigger a server-side update and verify the timestamp updates.

🤖 Generated with Claude Code

Add ResourceSubscriptionsState that mirrors InspectorClient's subscribed
URIs as InspectorResourceSubscription[], resolving each URI against the
managed resources list (so the subscription tile shows server-supplied
name/title) and stamping lastUpdated on notifications/resources/updated.
Pair it with useResourceSubscriptions and wire App.tsx so the Resources
screen reflects subscribe/unsubscribe actions in real time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cliffhall cliffhall added the v2 Issues and PRs for v2 label May 19, 2026
@cliffhall
Copy link
Copy Markdown
Member Author

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 19, 2026

Claude finished @cliffhall's task in 2m 0s —— View job


Claude PR Review

  • Read changed files
  • Compare against existing state stores / hooks patterns
  • Check test coverage and edge cases
  • Post review feedback

Overall: clean, well-scoped, and consistent with the existing v2 state-manager/hook layering. The design mirrors ManagedResourcesState + useManagedResources closely enough that a reader familiar with one will recognize the other. Tests are thorough — destroy idempotence, re-subscribe semantics, the resourcesChange optimization, and the statusChange teardown path are all exercised. Coverage claim looks plausible from the tests I read.

Nothing I'd call a blocker. A handful of nits and two small correctness questions below.

Notes

1. onResourceUpdated skips the dispatch on unsubscribed URIs but doesn't explain what would actually emit a stray one. core/mcp/state/resourceSubscriptionsState.ts:75

if (!this.subscribedUris.includes(uri)) return;

The comment says "the client already guards on subscribedResources before dispatching, but we re-check so out-of-order events can't resurrect a stale entry." Worth checking InspectorClient to confirm — the only dispatch site at inspectorClient.ts:825-826 is already guarded by this.subscribedResources.has(uri). The extra guard here is fine defense-in-depth, but the comment overstates the hazard; there is no known event ordering where the client dispatches resourceUpdated for a not-subscribed URI. Low-impact nit — either leave it, or soften the comment to "belt-and-braces; if a future change decouples dispatch from subscription state, this still stays correct."

2. Constructor does not prime from client.getSubscribedResources(). core/mcp/state/resourceSubscriptionsState.ts:37-50

The state starts with subscribedUris: [] unconditionally. If this store is ever constructed against an already-connected client with existing subscriptions (e.g. a future refactor that keeps the client across server-card reconnects), the subscription list will look empty until the next subscribe/unsubscribe. The current App.tsx wiring always creates the state alongside a fresh client, so this is not a live bug — but a one-liner would future-proof it:

this.subscribedUris = client.getSubscribedResources();
this.rebuild();

Not required for this PR; flagging so future work on client reuse doesn't get a silent empty-list regression.

3. statusChange handler dispatches a literal [] rather than this.getSubscriptions(). core/mcp/state/resourceSubscriptionsState.ts:85

this.dispatchTypedEvent("subscriptionsChange", []);

Functionally equivalent since this.subscriptions = [] was just set, but every other emission goes through getSubscriptions() (defensive copy). Tiny style nit — either use this.getSubscriptions() here for consistency, or note that the literal is intentional.

4. On resourcesChange with an active subscription whose resource disappeared from the server list, the subscription tile will "regress" to the synthetic { uri, name: uri } form — losing the previously-known server title. core/mcp/state/resourceSubscriptionsState.ts:131

This is consistent with the documented fallback for unknown URIs, but it may surprise users who subscribed when the resource was listed and then see the pretty name vanish after a list refresh. Two possible takes:

  • Accept the regression (current behavior) — the managed list is the source of truth.
  • Cache the last-seen Resource per URI inside the state (alongside lastUpdatedByUri) and prefer the cached value when the managed list no longer has the URI.

Either is defensible; the current choice is fine if it's deliberate. Worth a one-line rationale in the class doc comment if you keep it.

5. subscribedUris.includes(uri) is O(n) on every resourceUpdated. core/mcp/state/resourceSubscriptionsState.ts:75

Subscription counts are tiny in practice so this is meaningless performance-wise. Only mentioning because the onSubscriptionsChange handler already builds a Set for the unsubscribe-pruning pass (active), and it would be almost-free to keep that Set as a class field and reuse it for the includes check. Skip if you think it's not worth the bookkeeping.

6. Hook tests exercise the state prop transitioning to/from null, which is exactly the case App.tsx triggers across server switches. Good — the "resets to empty when the state prop becomes null" test at useResourceSubscriptions.test.tsx:44 is the kind of thing that's easy to skip and hard to debug later.

7. useResourceSubscriptions result shape — returns { subscriptions } while some sibling hooks return { items, refresh }. No refresh is needed here since subscriptions are event-driven; just confirming this was the intent. Consistent with useMessageLog's { messages } pattern.

Verdict

Ship it after deciding on the regression behavior in note 4 (keep or cache). The rest are nits that do not block merge.
· Branch: v2/issue-1325-resource-subscriptions-hook

- Soften the onResourceUpdated comment: the client's dispatch is already
  guarded by subscribedResources.has(uri), so the re-check is true
  defense-in-depth rather than guarding a known hazard.
- Use this.getSubscriptions() in the statusChange handler so every emit
  goes through the defensive-copy path.
- Document the deliberate fallback to a synthetic Resource when a
  previously-listed resource is removed from the managed list while
  the user is still subscribed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cliffhall
Copy link
Copy Markdown
Member Author

Thanks for the review! Walking through each note:

1. Overstated hazard comment on onResourceUpdated — fixed in 3e94585. Softened to "belt-and-braces; stays correct if a future change ever decouples dispatch from subscription state." The client's dispatch site at inspectorClient.ts:825-826 is indeed already guarded.

2. Prime from client.getSubscribedResources() — skipping for this PR. InspectorClientProtocol doesn't currently expose a list getter (only isSubscribedToResource(uri)), so this would require a protocol change. The App.tsx wiring always creates the state alongside a fresh client today, so there's no live bug to fix. Worth a follow-up if/when we start reusing clients across server switches.

3. statusChange handler dispatches literal [] — fixed in 3e94585. Now uses this.getSubscriptions() so every emit goes through the defensive-copy path.

4. resourcesChange regression when a resource disappears — keeping current behavior, documented in the class doc (3e94585). Rationale: the managed list is the source of truth, and displaying a stale name for a server-removed resource is arguably worse than the URI fallback. If users complain in practice we can add the per-URI cache as a follow-up.

5. subscribedUris.includes(uri) is O(n) — skipping. Subscription counts are tiny and the bookkeeping to keep a parallel Set in sync isn't worth it.

6. Hook test for state-prop transitioning to null — noted, thanks.

7. Hook result shape { subscriptions } — intentional; matches useMessageLog's { messages } pattern since subscriptions are event-driven and there's nothing to refresh().

validate, test:integration, and test:storybook all still pass.

@cliffhall
Copy link
Copy Markdown
Member Author

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 19, 2026

Claude finished @cliffhall's task in 53s —— View job


Claude finished @cliffhall's task — follow-up review

  • Read updated files from commit 3e94585
  • Verify previous review notes 1, 3, 4 were addressed correctly
  • Fresh pass over the full changeset
  • Post review feedback

Follow-up review

All three in-scope fixes from the prior round landed correctly and the rationale for skipping 2, 5, 7 is sound. I re-read the full changeset cold and don't see anything new worth flagging. Ready to merge from my end.

Verification of prior notes:

  1. onResourceUpdated comment softened at core/mcp/state/resourceSubscriptionsState.ts:75-78. Reads as genuine defense-in-depth now rather than implying a known hazard. Good.
  2. ⏭ Deliberately deferred — InspectorClientProtocol has no list getter today, and the current App.tsx wiring never hands this state a pre-populated client. Agreed this is a follow-up if/when client reuse becomes a thing.
  3. statusChange handler now routes through this.getSubscriptions() at core/mcp/state/resourceSubscriptionsState.ts:89. Every emit path is consistent.
  4. ✅ Class-doc comment at core/mcp/state/resourceSubscriptionsState.ts:12-15 now documents the regression-to-synthetic behavior as intentional. Rationale ("managed list is the source of truth") is the right call.
  5. ⏭ Skipping the Set micro-opt is correct — subscription counts are tiny and the parallel-state bookkeeping would cost more than it saves.
  6. ✅ Noted.
  7. { subscriptions } shape is the right match for an event-driven source with no refresh affordance. Matches useMessageLog's { messages } precedent.

Fresh pass — nothing new I'd block on. Two very minor observations that you are welcome to ignore:

  • App.tsx:346-348 — the setResourceSubscriptionsState(new ResourceSubscriptionsState(client, nextResourcesState)) pattern threads the freshly-constructed nextResourcesState in to dodge the useState update lag. The inline comment explains this clearly, which is exactly the kind of "WHY" comment that earns its keep. No change.
  • The subscriptions array is rebuilt on every resourcesChange, even when none of the subscribed URIs' resources actually changed shape. For real-world subscription counts this is a non-issue; mentioning only because a future perf pass on the resources list might notice it. Not for this PR.

Verdict

LGTM. Ship it.

--- · Branch: v2/issue-1325-resource-subscriptions-hook

cliffhall and others added 7 commits May 19, 2026 18:42
- Wire `isSubscribed` on the ReadResourceState passed to ResourcesScreen
  by deriving it from the live subscriptions list in App.tsx. The
  ResourcePreviewPanel's SubscribeButton already flips its label to
  "Unsubscribe" when subscribed; without this derivation isSubscribed
  was always false and the button looked stuck on "Subscribe".
- In ResourceSubscribedItem, display only the last URI path segment
  (truncated with ellipsis if it still overflows) and surface the full
  URI via a hover tooltip. Keeps the Subscriptions pleat readable when
  resources have long path-style URIs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clicking a resource in the URIs accordion now triggers onReadResource
in addition to setting the selection, so the preview panel jumps
straight to the pending → ok render path. Removes the unreachable
"Click to read this resource" placeholder, since the loader / preview /
error states always cover the rendered output for a selected resource.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add react-markdown / remark-gfm dependencies.
- ContentViewer accepts an optional mimeType prop; when it matches
  text/markdown (or text/x-markdown), text blocks render via
  react-markdown wrapped in a `.markdown-content` container. Non-markdown
  text now uses the existing Code "wrapping" variant so long lines stay
  inside the panel.
- ResourcePreviewPanel infers `text/markdown` from a `.md` / `.markdown`
  URI suffix when the server didn't supply a mimeType, and threads the
  effective mime through to ContentViewer per content item.
- ResourcesScreen flex children get miw=0 so a long unwrappable line in
  the resource body can no longer push the preview past the viewport's
  right edge.
- App.css gains a `.markdown-content` ruleset constraining nested pre,
  table, code, and img elements to the container's width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructure ResourcePreviewPanel into a fixed-height column with the
resource title + URI pinned to the top and the timestamp / annotations /
subscribe-refresh actions pinned to the bottom. The content viewer area
in the middle now owns its own ScrollArea, so a long markdown body
scrolls within the panel instead of pushing the subscribe button below
the viewport.

In ResourcesScreen, the selectedResource branch (and the template
branch's right pane) now hosts the panel inside a PreviewPane Flex
column with the screen's max-height, and renderReadState wraps in a
FillDetailCard sized to fill that column. The legacy outer
ScrollArea.Autosize is removed for these panes since scrolling is
internal now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- After the user clicks Read Resource on the template form,
  handleReadResource now clears selectedTemplateUri so the screen
  swaps from the template form to the resource preview. Previously
  both panels stayed mounted side-by-side.
- The preview no longer hard-fills the viewport. The Card uses a new
  "preview" theme variant (overflow: hidden) and is content-sized,
  capped at SCROLL_MAX_HEIGHT. ResourcePreviewPanel's flex column
  marks the header / meta / footer rows as `flex: 0 0 auto` and the
  ContentScroll as `flex: 0 1 auto` with `mih: 0`, so:
    - short content → card hugs it, footer sits right under the body
    - long content → card caps at viewport, ContentScroll shrinks
      and scrolls internally, footer stays pinned at the cap
- ScrollArea / Group imports in ResourcesScreen pruned to match the
  simpler single-pane layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A bare text input + Read Resource button stretched across the whole
main content area looks awkward on wide displays. Apply maw=40% to
the PreviewPane in the template branch so the form keeps a reasonable
form-field width regardless of viewport.

The preview branch is unaffected — resource bodies still get the full
main area for content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the server's `completions` capability through to the resource
template form so each variable's input becomes an Autocomplete that
fires `completion/complete` (debounced, 300ms) on every keystroke and
surfaces the returned values as a dropdown — mirroring v1's behavior.

- core/mcp/inspectorClientProtocol.ts: surface getCompletions on the
  protocol so non-runtime callers (state managers, hooks, tests) can
  depend on it. FakeInspectorClient gains a vi.fn-backed stub.
- ResourceTemplatePanel: accepts onCompleteArgument + completionsSupported.
  When both are present it renders Mantine Autocomplete instead of
  TextInput, debounces keystrokes via per-arg timers, aborts in-flight
  requests on the next keystroke, and disables client-side filtering
  (the server already filtered for the typed prefix).
- ResourcesScreen / InspectorView: thread the props through; the
  screen-level callback re-injects the active template's URI as the
  `ref: "ref/resource"` so the panel-level callback stays ref-free.
- App.tsx: wires onCompleteArgument to inspectorClient.getCompletions
  and derives completionsSupported from `capabilities?.completions`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cliffhall
Copy link
Copy Markdown
Member Author

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 20, 2026

Claude finished @cliffhall's task in 2m 43s —— View job


Claude PR Review (round 3)

  • Fetch latest branch state
  • Review seven new commits beyond the subscriptions hook
  • Inspect preview/template/markdown/auto-read changes
  • Post review feedback

LGTM in aggregate. The subscriptions hook work from the prior rounds is unchanged; the new material (markdown rendering, sized-to-content preview, template completions, auto-read, compact subscribed tile) is clean and consistent with the project's Mantine-first + theme-variant conventions. Three small correctness notes and some scope/nits below.

Scope

The PR title + body still describe only the subscriptions hook (#1325), but seven commits landed on top that expand into the resource preview/template UI. Worth either updating the PR description to reflect the full scope (so reviewers/future-you can see what actually shipped), or splitting into follow-up PRs. Not a blocker; just surfacing it so the commit history stays findable.

Correctness notes

1. Possible stale-read race in onReadResource. clients/web/src/App.tsx:472-493

If the user clicks resource A, then clicks resource B before A's inspectorClient.readResource resolves, A's late resolution calls setReadResourceState({ status: "ok", uri: "A", result: A_contents, … }) — clobbering B's pending. ResourcesScreen renders <ResourcePreviewPanel resource={readResource} contents={readState.result.contents} /> with readResource derived from selectedResourceUri (= B) but contents from readState.result (= A's contents). Header says B, body is A.

Easy guard: tag each call with a request-id ref and only commit setReadResourceState if the in-flight id still matches the latest dispatch. Or, since readState.uri is already tracked, gate the setter on uri === latestUriRef.current. Follow-up is fine — I don't think this is reachable in the happy path described in the manual test plan, but rapid clicking in the sidebar (which now auto-reads per 13b03dd6) is the exact interaction that triggers it.

2. Completion "abort" is soft-abort only. clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx:143-164

requestsRef.current.get(varName)?.abort() flips the controller's signal.aborted, and runCompletion checks it before applying results — so stale responses can't overwrite fresh ones. But onCompleteArgument never receives the signal (the App.tsx:511 wrapper calls inspectorClient.getCompletions without one), so the network request still runs to completion. That's fine for correctness — just noting that the in-flight "abort" is purely a client-side discard. If getCompletions ever grows a signal parameter, plumb it through here. Not for this PR.

3. Fast-typing + pending fulfilled request ordering. Same file, same callback.

Sequence: user types "a", 300ms debounce fires, runCompletion("a") starts. User types "ab" during the in-flight request — debounce restarts, 300ms later runCompletion("ab") starts and aborts "a"'s controller. If the "a" network request resolves after the "ab" one, the "a" result is discarded (aborted) and the "ab" result is applied. Correct. If they resolve in order, the "ab" result overwrites. Also correct. The only corner is if runCompletion is called twice for the same varName synchronously (shouldn't happen under the current debounce), the second call aborts the first's controller and replaces it. Looks fine — confirming the invariant in passing.

Nits

  • formatLastUpdated duplicated across ResourcePreviewPanel.tsx:50 (prefixed "Last updated: ") and ResourceSubscribedItem.tsx:37 (bare date.toLocaleString()). Different enough to keep local but worth a glance to confirm the prefix discrepancy is intentional.

  • readResource fallback uses the URI for both name and uri. clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx:123-127. Same pattern as ResourceSubscriptionsState's synthetic-resource fallback, so consistent — but the synthetic name-equals-URI will render in the tile whenever the user reads from a template before the resources list catches up. Fine for now; flagging so the two fallback sites stay in sync if one evolves.

  • inferMimeFromUri at ResourcePreviewPanel.tsx:133 handles .md/.markdown. Worth considering .mdx too — low priority, MCP servers serving MDX is speculative.

  • ResourcesScreen layout comments (lines 91-92, 217-221, 242-250) earn their keep — these explain the flex-shrink / mah=SCROLL_MAX_HEIGHT interplay that would otherwise look arbitrary. Good "WHY" comments.

  • theme/Card.ts preview variant correctly placed in the theme (flat CSS properties only) rather than in App.css — follows the CLAUDE.md guidance well.

  • Markdown styles in App.css (.markdown-content > :first-child, pre, table, img nested selectors) are legitimately in App.css per the rule about "nested child-element selectors for third-party HTML output." Correct placement.

  • Autocomplete filter={({ options }) => options} is the right call — the server already did the filtering against the typed prefix, and the comment explains why. Mantine's default substring filter would hide valid server suggestions when the input doesn't locally substring-match.

Tests

Coverage on the new surface looks solid. ResourceTemplatePanel.test.tsx exercises the debounce + context-passing + completionsSupported gate. ResourcePreviewPanel.test.tsx covers the markdown branch, mime inference, and blob fallbacks. ResourcesScreen.test.tsx covers auto-read on click and template → preview handoff.

Missing but not critical: an explicit test for the runCompletion abort-on-new-keystroke path (ensuring the earlier response is dropped), and one for the unmount cleanup cancelling pending timers + aborting in-flight controllers. If you want belt-and-braces, add; otherwise defer.

Verdict

Ship it. The stale-read race in note 1 is the only thing I'd prioritize, and only as a follow-up — it's a corner case that's easy to hit now that sidebar click auto-reads.
· Branch: v2/issue-1325-resource-subscriptions-hook

@cliffhall cliffhall merged commit 6678313 into v2/main May 20, 2026
1 check passed
@cliffhall cliffhall deleted the v2/issue-1325-resource-subscriptions-hook branch May 20, 2026 00:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant