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
358 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|