Resources: subscriptions/listen model on modern servers (era fork) (#1630) (#1730)

Fork resource subscriptions by protocol era: legacy keeps resources/subscribe; modern (2026-07-28) models subscriptions as a filter over one long-lived subscriptions/listen stream with reconnect-by-re-listen (capped backoff), an optimistic Connecting→Listening header badge, and a fix so the browser proxy no longer blocks listen() ~60s. Closes #1630.
This commit is contained in:
Cliff Hall 2026-07-20 19:33:40 -04:00 • committed by GitHub
commit 320daf2f67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1656 additions and 90 deletions

View file

@ -0,0 +1,26 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SubscriptionStreamBadge } from "./SubscriptionStreamBadge";
const meta: Meta<typeof SubscriptionStreamBadge> = {
title: "Elements/SubscriptionStreamBadge",
component: SubscriptionStreamBadge,
};
export default meta;
type Story = StoryObj<typeof SubscriptionStreamBadge>;
export const Connecting: Story = {
args: { status: "connecting" },
};
export const Acknowledged: Story = {
args: { status: "acknowledged" },
};
export const Reconnecting: Story = {
args: { status: "reconnecting" },
};
export const Ended: Story = {
args: { status: "ended" },
};

View file

@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { SubscriptionStreamBadge } from "./SubscriptionStreamBadge";
import { subscriptionStreamPresentation } from "./subscriptionStreamUtils";
describe("subscriptionStreamPresentation", () => {
it("maps each status to a color and label", () => {
expect(subscriptionStreamPresentation("connecting")).toMatchObject({
color: "blue",
label: "Connecting…",
});
expect(subscriptionStreamPresentation("acknowledged")).toMatchObject({
color: "green",
label: "Listening",
});
expect(subscriptionStreamPresentation("reconnecting")).toMatchObject({
color: "yellow",
label: "Reconnecting…",
});
expect(subscriptionStreamPresentation("ended")).toMatchObject({
color: "gray",
label: "Stream ended",
});
});
it("explains the listen stream in every tooltip", () => {
for (const status of [
"connecting",
"acknowledged",
"reconnecting",
"ended",
] as const) {
expect(subscriptionStreamPresentation(status).tooltip).toContain(
"subscriptions/listen stream",
);
}
});
});
describe("SubscriptionStreamBadge", () => {
it("renders a labelled badge by default", () => {
renderWithMantine(<SubscriptionStreamBadge status="acknowledged" />);
expect(screen.getByText("Listening")).toBeInTheDocument();
});
it("renders a labelled reconnecting badge", () => {
renderWithMantine(<SubscriptionStreamBadge status="reconnecting" />);
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
});
it("renders a labelled ended badge", () => {
renderWithMantine(<SubscriptionStreamBadge status="ended" />);
expect(screen.getByText("Stream ended")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,27 @@
import { Badge, Tooltip } from "@mantine/core";
import type { ResourceSubscriptionStreamStatus } from "../../../../../../core/mcp/types.js";
import { subscriptionStreamPresentation } from "./subscriptionStreamUtils";
export interface SubscriptionStreamBadgeProps {
/** Lifecycle status of the modern `subscriptions/listen` stream. */
status: ResourceSubscriptionStreamStatus;
}
/**
* Status indicator for the modern-era resource-subscription listen stream
* (#1630). Only meaningful on the modern era — the caller gates rendering on
* `streamState.active`. Renders a labelled dot badge (green/yellow/gray) in the
* Subscriptions section header, explaining the stream in a tooltip.
*/
export function SubscriptionStreamBadge({
status,
}: SubscriptionStreamBadgeProps) {
const { color, label, tooltip } = subscriptionStreamPresentation(status);
return (
<Tooltip label={tooltip} multiline w={260} withArrow>
<Badge variant="dot" color={color}>
{label}
</Badge>
</Tooltip>
);
}

View file

@ -0,0 +1,53 @@
import type { ResourceSubscriptionStreamStatus } from "../../../../../../core/mcp/types.js";
export interface StreamPresentation {
/** Mantine palette color name conveying the status. */
color: string;
/** Short label shown on the panel badge. */
label: string;
/** Full explanation shown in the tooltip (both variants). */
tooltip: string;
}
const STREAM_INTRO =
"On modern (2026-07-28) servers, resource subscriptions are a filter over one long-lived subscriptions/listen stream.";
// Keyed by status so it's exhaustive at compile time: a new
// `ResourceSubscriptionStreamStatus` that isn't handled here is a type error
// (no unreachable `default` needed, so it stays fully covered).
const PRESENTATION: Record<
ResourceSubscriptionStreamStatus,
StreamPresentation
> = {
connecting: {
color: "blue",
label: "Connecting…",
tooltip: `${STREAM_INTRO} Opening the stream — waiting for the server to acknowledge the subscription.`,
},
acknowledged: {
color: "green",
label: "Listening",
tooltip: `${STREAM_INTRO} The server acknowledged the subscription and the stream is open, carrying resources/updated notifications.`,
},
reconnecting: {
color: "yellow",
label: "Reconnecting…",
tooltip: `${STREAM_INTRO} The stream dropped unexpectedly; re-listening to re-establish it (there is no resumability, so the full filter is re-sent).`,
},
ended: {
color: "gray",
label: "Stream ended",
tooltip: `${STREAM_INTRO} The stream is closed and won't reconnect on its own — either the server ended it (for example, on shutdown) or reconnection was abandoned after repeated failures. Re-subscribe to try again.`,
},
};
/**
* Maps a modern listen-stream status to its badge color, label, and tooltip
* copy (#1630). Kept in its own module so the badge component file exports only
* a component (react-refresh rule).
*/
export function subscriptionStreamPresentation(
status: ResourceSubscriptionStreamStatus,
): StreamPresentation {
return PRESENTATION[status];
}

View file

@ -176,7 +176,7 @@ const subscriptionNotificationEntry: MessageEntry = {
jsonrpc: "2.0",
method: "notifications/resources/list_changed",
params: {
_meta: { "io.modelcontextprotocol/subscriptionId": "sub-abc123" },
_meta: { "io.modelcontextprotocol/subscriptionId": "listen:0" },
},
},
};
@ -231,7 +231,8 @@ export const ModernInputRequired: Story = {
},
};
// Modern-era: a `server/discover` probe, flagged with the "modern" frame badge.
// Modern-era: a `server/discover` probe (a modern-only method). The connection
// era is shown once in the panel header, not per entry.
export const ModernDiscoverFrame: Story = {
args: {
entry: discoverEntry,
@ -249,6 +250,25 @@ export const ModernSubscriptionNotification: Story = {
},
};
// Embedded (monitoring-sidebar) layout of a subscription notification: the
// `sub … listen-id` tag rides the top line (beside the direction) so the method
// badge on the line below gets the full narrow-column width (#1630).
export const EmbeddedSubscriptionNotification: Story = {
args: {
entry: subscriptionNotificationEntry,
isPinned: false,
isListExpanded: false,
embedded: true,
},
decorators: [
(Story) => (
<Box w={340}>
<Story />
</Box>
),
],
};
// Embedded (monitoring-sidebar) layout with a long resource URI: the target sits
// in a horizontal scroll area rather than truncating with an ellipsis. Wrapped
// in the pinned column's width so the overflow is visible.

View file

@ -471,14 +471,10 @@ describe("ProtocolEntry — modern vocabulary", () => {
expect(screen.queryByText("complete")).not.toBeInTheDocument();
});
it("flags a modern-only frame with a 'modern' badge", () => {
it("renders a modern-only frame's method (no per-frame era badge)", () => {
renderWithMantine(<ProtocolEntry {...baseProps} entry={discoverEntry} />);
expect(screen.getByText("server/discover")).toBeInTheDocument();
expect(screen.getByText("modern")).toBeInTheDocument();
});
it("does not flag an ordinary frame as modern", () => {
renderWithMantine(<ProtocolEntry {...baseProps} entry={successEntry} />);
// The connection era is shown once in the panel header, not per entry.
expect(screen.queryByText("modern")).not.toBeInTheDocument();
});
@ -494,6 +490,17 @@ describe("ProtocolEntry — modern vocabulary", () => {
).toBeGreaterThanOrEqual(1);
});
it("shows the subscriptionId in the embedded compact layout", () => {
renderWithMantine(
<ProtocolEntry
{...baseProps}
entry={subscriptionNotificationEntry}
embedded
/>,
);
expect(screen.getByText("sub-abc")).toBeInTheDocument();
});
it("shows the modern badges in the embedded compact layout", () => {
renderWithMantine(
<ProtocolEntry {...baseProps} entry={inputRequiredEntry} embedded />,

View file

@ -29,7 +29,6 @@ import {
extractMethod,
extractResultType,
extractSubscriptionId,
isModernFrame,
isReplayableProtocolMethod,
} from "../protocolUtils.js";
@ -109,15 +108,6 @@ const DurationText = Text.withProps({
c: "dimmed",
});
// Flags a frame whose method only exists in the modern (2026-07-28) era
// (`server/discover`, `subscriptions/listen`, its acknowledgement). This labels
// the *frame*, not the connection's negotiated era (see the transcript caveat in
// protocolUtils) — those methods genuinely cannot occur on a legacy connection.
const ModernFrameBadge = Badge.withProps({
color: "blue",
variant: "outline",
});
// The `subscriptionId` tag on a modern push notification (spec §7.4). Shown with
// a copy button so the id can be correlated against the `subscriptions/listen`
// stream that opened it.
@ -280,7 +270,6 @@ export function ProtocolEntry({
const canReplay = isReplayableProtocolMethod(method);
const resultType = extractResultType(entry);
const subscriptionId = extractSubscriptionId(entry);
const isModern = isModernFrame(method);
useEffect(() => {
setIsExpanded(isListExpanded);
@ -319,9 +308,6 @@ export function ProtocolEntry({
{statusLabel(status)}
</Badge>
);
const modernFrameBadge = isModern && (
<ModernFrameBadge>modern</ModernFrameBadge>
);
const subscriptionBadge = subscriptionId && (
<SubscriptionCluster>
<SubscriptionLabel>sub</SubscriptionLabel>
@ -350,13 +336,16 @@ export function ProtocolEntry({
{durationText}
{resultTypeBadge}
{statusBadge}
{/* The subscription-id tag rides the top line's trailing edge
(a notification row's duration/status slots are empty) so the
method badge on the line below gets the full column width and
doesn't truncate against the pin control (#1630). */}
{subscriptionBadge}
</ControlsCluster>
</HeaderRow>
<HeaderRow>
<HeaderCluster flex={1}>
<MethodBadge method={method} />
{modernFrameBadge}
{subscriptionBadge}
{target && (
<>
{resourceUri && <CopyButton value={resourceUri} />}
@ -395,7 +384,6 @@ export function ProtocolEntry({
{directionBadge}
<MethodBadge method={method} />
{specErrorBadge}
{modernFrameBadge}
{subscriptionBadge}
{target && (
<>

View file

@ -333,4 +333,66 @@ describe("ResourceControls", () => {
await user.type(screen.getByPlaceholderText("Search..."), "special");
expect(screen.getByText("URIs (1)")).toBeInTheDocument();
});
describe("modern listen-stream chrome (#1630)", () => {
const activeAck = {
active: true as const,
status: "acknowledged" as const,
honoredUris: ["file:///config.json"],
};
it("shows the stream badge in the section header on the modern era", () => {
renderWithMantine(
<ControlledResourceControls
protocolEra="modern"
subscriptionStreamState={activeAck}
/>,
);
// The labelled badge sits in the accordion header (next to the count).
expect(screen.getByText("Listening")).toBeInTheDocument();
});
it("renders no stream chrome on the legacy era even when a stream is active", () => {
renderWithMantine(
<ControlledResourceControls subscriptionStreamState={activeAck} />,
);
expect(screen.queryByText("Listening")).not.toBeInTheDocument();
});
it("renders no stream chrome on the modern era when the stream is inactive", () => {
renderWithMantine(
<ControlledResourceControls
protocolEra="modern"
subscriptionStreamState={{
active: false,
status: "ended",
honoredUris: [],
}}
/>,
);
expect(
screen.queryByText(/Listening|Stream ended/),
).not.toBeInTheDocument();
});
it("hides the stream badge when a search filters out all subscriptions", async () => {
const user = userEvent.setup();
renderWithMantine(
<ControlledResourceControls
protocolEra="modern"
subscriptionStreamState={activeAck}
/>,
);
expect(screen.getByText("Listening")).toBeInTheDocument();
// A query matching none of the (live) subscriptions empties the section;
// the badge hides with it rather than sitting next to "Subscriptions (0)".
await user.type(
screen.getByPlaceholderText("Search..."),
"no-such-resource",
);
expect(screen.getByText("Subscriptions (0)")).toBeInTheDocument();
expect(screen.queryByText("Listening")).not.toBeInTheDocument();
});
});
});

View file

@ -1,11 +1,17 @@
import { Accordion, Group, Stack, TextInput, Title } from "@mantine/core";
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 } from "../../../../../../core/mcp/types.js";
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 {
ListPaginationControls,
@ -26,6 +32,15 @@ export interface ResourceControlsProps {
* 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.
*/
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,
@ -71,6 +86,8 @@ export function ResourceControls({
templates,
subscriptions,
subscriptionsSupported = true,
subscriptionStreamState,
protocolEra,
selectedUri,
selectedTemplateUri,
searchText = "",
@ -106,6 +123,20 @@ export function ResourceControls({
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.
@ -271,10 +302,17 @@ export function ResourceControls({
)}
>
<Accordion.Control disabled={filteredSubscriptions.length === 0}>
{formatSectionCount(
"Subscriptions",
filteredSubscriptions.length,
)}
<Group gap="xs" wrap="nowrap">
<Text span>
{formatSectionCount(
"Subscriptions",
filteredSubscriptions.length,
)}
</Text>
{streamStatus && (
<SubscriptionStreamBadge status={streamStatus} />
)}
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">

View file

@ -7,7 +7,6 @@ import {
extractResultType,
extractSubscriptionId,
groupProtocolEntries,
isModernFrame,
} from "./protocolUtils";
// A tools/call request entry whose paired response is the given result. `at`
@ -86,22 +85,6 @@ describe("extractMethod", () => {
});
});
describe("isModernFrame", () => {
it.each([
"server/discover",
"subscriptions/listen",
"notifications/subscriptions/acknowledged",
])("recognizes the modern frame %s", (method) => {
expect(isModernFrame(method)).toBe(true);
});
it("returns false for legacy/ordinary methods", () => {
expect(isModernFrame("tools/call")).toBe(false);
expect(isModernFrame("notifications/initialized")).toBe(false);
expect(isModernFrame("response")).toBe(false);
});
});
describe("extractResultType", () => {
it("returns 'input_required' when the result carries the discriminator", () => {
const entry = callEntry(

View file

@ -50,21 +50,6 @@ export function isReplayableProtocolMethod(method: string): boolean {
// envelope/resultType" ≠ "modern negotiated" (spec §8.3). Era labeling comes
// from the negotiated `protocolEra` connection state, threaded in as a prop.
/**
* Frame methods that only exist in the modern era: the discovery probe, the
* push-notification listen stream, and its acknowledgement. Generic viewers
* already display them; this set drives the small "modern frame" affordance.
*/
export const MODERN_METHODS: ReadonlySet<string> = new Set([
"server/discover",
"subscriptions/listen",
"notifications/subscriptions/acknowledged",
]);
export function isModernFrame(method: string): boolean {
return MODERN_METHODS.has(method);
}
// The successful `result` object of a request entry's paired response, or
// undefined when there is no response or it was an error. (messageLogState folds
// a request's response onto the request entry by JSON-RPC id.)

View file

@ -239,6 +239,27 @@ export const SubscriptionsUnsupported: Story = {
},
};
// Modern (2026-07-28) era: subscriptions ride one `subscriptions/listen`
// stream, so the Subscriptions section shows a stream-status badge in its panel
// and a status dot in its header (#1630).
export const ModernSubscriptionStream: Story = {
args: {
resources: sampleResources,
templates: sampleTemplates,
subscriptions: sampleSubscriptions,
protocolEra: "modern",
subscriptionStreamState: {
active: true,
status: "acknowledged",
honoredUris: sampleSubscriptions.map((s) => s.resource.uri),
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText("Listening")).toBeInTheDocument();
},
};
const manyResources: Resource[] = Array.from({ length: 40 }, (_, i) => ({
name: `resource-${String(i + 1).padStart(2, "0")}.wav`,
uri: `file:///kit/resource-${i + 1}.wav`,

View file

@ -9,11 +9,15 @@ import {
Text,
} from "@mantine/core";
import type {
ProtocolEra,
ReadResourceResult,
Resource,
ResourceTemplateType as ResourceTemplate,
} from "@modelcontextprotocol/client";
import type { InspectorResourceSubscription } from "../../../../../../core/mcp/types.js";
import type {
InspectorResourceSubscription,
ResourceSubscriptionStreamState,
} from "../../../../../../core/mcp/types.js";
import { ResourceControls } from "../../groups/ResourceControls/ResourceControls";
import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls";
import { ResourcePreviewPanel } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel";
@ -32,6 +36,14 @@ export interface ResourcesScreenProps {
resources: Resource[];
templates: ResourceTemplate[];
subscriptions: InspectorResourceSubscription[];
/**
* Modern-era `subscriptions/listen` stream state (#1630). On the modern era
* the Subscriptions section shows a stream-status badge and a header dot;
* `active: false` (the legacy default) renders neither.
*/
subscriptionStreamState?: ResourceSubscriptionStreamState;
/** Negotiated protocol era; gates the modern subscription stream chrome. */
protocolEra?: ProtocolEra;
readState?: ReadResourceState;
ui: ResourcesUiState;
listChanged: boolean;
@ -137,6 +149,8 @@ export function ResourcesScreen({
resources,
templates,
subscriptions,
subscriptionStreamState,
protocolEra,
readState,
ui,
listChanged,
@ -291,6 +305,8 @@ export function ResourcesScreen({
templates={templates}
subscriptions={subscriptions}
subscriptionsSupported={subscriptionsSupported}
subscriptionStreamState={subscriptionStreamState}
protocolEra={protocolEra}
selectedUri={selectedResourceUri}
selectedTemplateUri={selectedTemplateUri}
searchText={search}

View file

@ -32,6 +32,7 @@ import type {
FetchRequestEntry,
InspectorResourceSubscription,
MessageEntry,
ResourceSubscriptionStreamState,
ServerEntry,
StderrLogEntry,
} from "@inspector/core/mcp/types.js";
@ -394,6 +395,12 @@ export interface InspectorViewProps {
resources: Resource[];
resourceTemplates: ResourceTemplate[];
subscriptions: InspectorResourceSubscription[];
/**
* Modern-era `subscriptions/listen` stream state (#1630). Drives the
* Resources screen's stream badge/dot; `active: false` (or omitted) on the
* legacy era.
*/
subscriptionStreamState?: ResourceSubscriptionStreamState;
// "List changed since last refresh" flags, sourced from the managed-state
// layer (#1402). They light the per-screen list-changed indicator. Apps is a
@ -593,6 +600,7 @@ export function InspectorView({
promptsListChanged,
resourcesListChanged,
subscriptions,
subscriptionStreamState,
logs,
tasks,
progressByTaskId,
@ -1352,6 +1360,8 @@ export function InspectorView({
resources={resources}
templates={resourceTemplates}
subscriptions={subscriptions}
subscriptionStreamState={subscriptionStreamState}
protocolEra={protocolEra}
readState={readResourceState}
ui={resourcesUi}
listChanged={resourcesListChanged}