inspector/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx
cliffhall 0325d81493 fix: open the modern listen stream whenever the filter is non-empty (#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
2026-08-10 23:25:33 -04:00

358 lines
14 KiB
TypeScript

import { Accordion, Group, Stack, Text, TextInput, Title } from "@mantine/core";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import { RiArrowRightSLine } from "react-icons/ri";
import type {
ProtocolEra,
Resource,
ResourceTemplateType as ResourceTemplate,
} from "@modelcontextprotocol/client";
import type {
InspectorResourceSubscription,
ResourceSubscriptionStreamState,
} from "../../../../../../core/mcp/types.js";
import { isModernEra } from "../../elements/EraBadge/eraUtils";
import { SubscriptionStreamBadge } from "../../elements/SubscriptionStreamBadge/SubscriptionStreamBadge";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { ListLoadError } from "../../elements/ListLoadError/ListLoadError";
import {
ListPaginationControls,
type ListPaginationControlsProps,
} from "../../elements/ListPaginationControls/ListPaginationControls";
import { ListToggle } from "../../elements/ListToggle/ListToggle";
import { ResourceListItem } from "../ResourceListItem/ResourceListItem";
import { ResourceSubscribedItem } from "../ResourceSubscribedItem/ResourceSubscribedItem";
// A tight, non-wrapping horizontal row (search field + toggle; count + stream
// badge).
const TightRow = Group.withProps({ gap: "xs", wrap: "nowrap" });
// Fills the full-height `sidebar` Card (flex column) so the scroll region below
// can claim the remaining space; `mih: 0` lets that child shrink and scroll
// instead of overflowing the card (#1462).
const SidebarStack = Stack.withProps({ gap: "sm", flex: 1, mih: 0 });
const SearchInput = TextInput.withProps({
flex: 1,
placeholder: "Search...",
rightSectionPointerEvents: "auto",
});
export interface ResourceControlsProps {
resources: Resource[];
templates: ResourceTemplate[];
subscriptions: InspectorResourceSubscription[];
/**
* Whether the connected server advertises the `resources.subscribe`
* capability. When false, the Subscriptions accordion section is hidden
* entirely. Defaults to true so the section renders unless a caller
* explicitly marks subscriptions unsupported.
*/
subscriptionsSupported?: boolean;
/**
* Modern-era `subscriptions/listen` stream state (#1630). When `active`
* (modern era with at least one subscription) the Subscriptions section shows
* a stream-status badge in its panel and a status dot in its header. Legacy
* connections pass `active: false` (or omit it) and see neither — and so does
* a stream open purely for list-change notifications, which this section has
* nothing to say about (#1920).
*/
subscriptionStreamState?: ResourceSubscriptionStreamState;
/** Negotiated protocol era; gates the modern subscription stream chrome. */
protocolEra?: ProtocolEra;
selectedUri?: string;
selectedTemplateUri?: string;
// Search text + accordion open-sections are controlled by the parent (App,
// via ResourcesScreen) so they persist across tab navigation within a live
// session — see #1417. `openSections` is optional: when undefined the
// accordion falls back to the `compact`-derived default below.
searchText?: string;
openSections?: string[];
listChanged: boolean;
onRefreshList: () => void;
/**
* A failed list load, surfaced above the list instead of leaving the panel
* empty (which reads as "this server has none") (#1953).
*/
loadError?: Error | null;
/** Pagination controls for the Resources list (#1721). */
pagination: ListPaginationControlsProps;
onSearchChange: (value: string) => void;
onOpenSectionsChange: (value: string[]) => void;
onSelectUri: (uri: string) => void;
onSelectTemplate: (uriTemplate: string) => void;
onUnsubscribeResource: (uri: string) => void;
/**
* Persisted preference for the ListToggle. Seeds initial accordion state
* (when `openSections` is undefined); the user can still toggle individual
* sections during a session without affecting this value. Only an explicit
* ListToggle click updates it.
*/
compact: boolean;
onCompactChange: (next: boolean) => void;
}
function formatSectionCount(label: string, count: number): string {
return `${label} (${count})`;
}
// Per-section flex for the full-height accordion. Open sections share the
// remaining height; `flex-shrink` is weighted by item count (so a long section
// gives up space to shorter ones before they have to scroll) and `flex-grow` is
// 0 so nothing expands — or scrolls — until the combined content overflows the
// panel. Closed/empty sections stay at their header height (#1462).
function sectionFlex(open: boolean, count: number): string {
return open && count > 0 ? `0 ${count} auto` : "0 0 auto";
}
export function ResourceControls({
resources,
templates,
subscriptions,
subscriptionsSupported = true,
subscriptionStreamState,
protocolEra,
selectedUri,
selectedTemplateUri,
searchText = "",
openSections: controlledOpenSections,
listChanged,
onRefreshList,
loadError,
pagination,
onSearchChange,
onOpenSectionsChange,
onSelectUri,
onSelectTemplate,
onUnsubscribeResource,
compact: initialCompact,
onCompactChange,
}: ResourceControlsProps) {
const query = searchText.toLowerCase();
const filteredResources = resources.filter(
(r) =>
r.name.toLowerCase().includes(query) ||
(r.title?.toLowerCase().includes(query) ?? false) ||
r.uri.toLowerCase().includes(query),
);
const filteredTemplates = templates.filter(
(t) =>
t.name.toLowerCase().includes(query) ||
(t.title?.toLowerCase().includes(query) ?? false) ||
t.uriTemplate.toLowerCase().includes(query),
);
const filteredSubscriptions = subscriptions.filter(
(s) =>
s.resource.name.toLowerCase().includes(query) ||
(s.resource.title?.toLowerCase().includes(query) ?? false) ||
s.resource.uri.toLowerCase().includes(query),
);
// Modern-era chrome for the single `subscriptions/listen` stream (#1630):
// a status badge in the section header (so it stays visible while the section
// is collapsed). Only shown on the modern era while the stream is active
// (≥1 subscription); the legacy per-URI `resources/subscribe` model has no
// persistent stream. Also gated on the *filtered* count so the badge hides
// alongside the section when a search matches none of the live subscriptions
// (rather than sitting next to a disabled "Subscriptions (0)" header).
const streamStatus =
isModernEra(protocolEra) &&
subscriptionStreamState?.active === true &&
filteredSubscriptions.length > 0
? subscriptionStreamState.status
: undefined;
// Subscriptions are only meaningful when the server advertises the
// `resources.subscribe` capability; otherwise the section is omitted
// entirely (no header, no panel) — see #1478.
const allSections = subscriptionsSupported
? ["resources", "templates", "subscriptions"]
: ["resources", "templates"];
// Open-sections is parent-controlled (persists across navigation). When the
// parent hasn't set it yet (undefined), fall back to the persisted `compact`
// preference: empty when last left compact, all sections open when expanded.
// Per-section accordion clicks update the lifted value but don't change the
// persisted preference.
const openSections =
controlledOpenSections ?? (initialCompact ? [] : [...allSections]);
// Persisted open-sections may still carry "subscriptions" from a prior
// subscription-capable session, so compare only the sections we actually
// render when deciding whether everything is expanded.
const allExpanded =
openSections.filter((section) => allSections.includes(section)).length ===
allSections.length;
// Empty sections have a disabled control and nothing to show, so keep them
// out of the accordion's open set — they render collapsed (chevron points
// right) rather than as an open-but-empty panel (#1462). `openSections` still
// tracks the user's intent (and seeds the ListToggle), so a section re-opens
// on its own once it has items again.
const sectionItemCounts: Record<string, number> = {
resources: filteredResources.length,
templates: filteredTemplates.length,
subscriptions: filteredSubscriptions.length,
};
const visibleOpenSections = openSections.filter(
(section) =>
allSections.includes(section) && (sectionItemCounts[section] ?? 0) > 0,
);
// Open-in-intent but currently empty (so excluded from the accordion's
// `value`). Mantine derives the next open-array by toggling the clicked
// section against the `value` we hand it, which omits these — so without
// merging them back, toggling any populated section would silently drop an
// empty section's intent and it wouldn't reappear once it has items again.
// Restricted to `allSections` so a stale "subscriptions" entry persisted from
// a prior subscription-capable session isn't perpetually re-appended once the
// section is no longer rendered — it's dropped from persisted state instead.
const intendedButEmptySections = openSections.filter(
(section) =>
allSections.includes(section) && !visibleOpenSections.includes(section),
);
function handleOpenSectionsChange(next: string[]) {
// Safe to append unconditionally: empty-section controls are `disabled`, so
// the user can never toggle one and `next` never contains an empty section
// — no double-add, and a section the user just closed can't be resurrected.
onOpenSectionsChange([...next, ...intendedButEmptySections]);
}
function handleToggleList() {
// Compute the next compact value from what the click will produce so a
// half-open accordion (user toggled a single section) still persists the
// right preference: clicking "expand all" should record `compact=false`
// even if the visible state was already partially expanded.
const nextCompact = allExpanded;
onOpenSectionsChange(nextCompact ? [] : [...allSections]);
onCompactChange(nextCompact);
}
return (
<SidebarStack>
<Group justify="space-between">
<Title order={4}>Resources</Title>
<ListChangedIndicator visible={listChanged} onRefresh={onRefreshList} />
</Group>
<TightRow>
<SearchInput
value={searchText}
onChange={(e) => onSearchChange(e.currentTarget.value)}
rightSection={
searchText ? (
<ClearButton onClick={() => onSearchChange("")} />
) : null
}
/>
<ListToggle compact={!allExpanded} onToggle={handleToggleList} />
</TightRow>
<ListPaginationControls {...pagination} />
<ListLoadError
error={loadError}
what="resources"
onRetry={onRefreshList}
/>
{/* Stays inline: Accordion is a compound, `multiple`-discriminated generic,
so `.withProps({ multiple: true, ... })` loses its JSX call signature
(same tooling limit as Box). */}
<Accordion
multiple
variant="disclosure"
chevron={<RiArrowRightSLine />}
flex={1}
mih={0}
// Disable Mantine's panel height animation so flex controls the height
// cleanly (the chevron still rotates smoothly via CSS in App.css). #1462
transitionDuration={0}
value={visibleOpenSections}
onChange={handleOpenSectionsChange}
>
<Accordion.Item
value="resources"
flex={sectionFlex(
visibleOpenSections.includes("resources"),
filteredResources.length,
)}
>
<Accordion.Control disabled={filteredResources.length === 0}>
{formatSectionCount("URIs", filteredResources.length)}
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
{filteredResources.map((resource) => (
<ResourceListItem
key={resource.uri}
resource={resource}
selected={resource.uri === selectedUri}
onClick={() => {
if (resource.uri !== selectedUri) onSelectUri(resource.uri);
}}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item
value="templates"
flex={sectionFlex(
visibleOpenSections.includes("templates"),
filteredTemplates.length,
)}
>
<Accordion.Control disabled={filteredTemplates.length === 0}>
{formatSectionCount("Templates", filteredTemplates.length)}
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
{filteredTemplates.map((template) => (
<ResourceListItem
key={template.uriTemplate}
resource={template}
selected={template.uriTemplate === selectedTemplateUri}
onClick={() => {
if (template.uriTemplate !== selectedTemplateUri)
onSelectTemplate(template.uriTemplate);
}}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
{subscriptionsSupported && (
<Accordion.Item
value="subscriptions"
flex={sectionFlex(
visibleOpenSections.includes("subscriptions"),
filteredSubscriptions.length,
)}
>
<Accordion.Control disabled={filteredSubscriptions.length === 0}>
<TightRow>
<Text span>
{formatSectionCount(
"Subscriptions",
filteredSubscriptions.length,
)}
</Text>
{streamStatus && (
<SubscriptionStreamBadge status={streamStatus} />
)}
</TightRow>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
{filteredSubscriptions.map((sub) => (
<ResourceSubscribedItem
key={sub.resource.uri}
subscription={sub}
onUnsubscribe={() =>
onUnsubscribeResource(sub.resource.uri)
}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
)}
</Accordion>
</SidebarStack>
);
}