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:
parent
a71e6d2012
commit
320daf2f67
7 changed files with 1656 additions and 90 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
Loading…
Reference in a new issue