fix(core): say why a never-acknowledged subscriptions/listen ended

A modern-era `subscriptions/listen` is a long-lived request: the first
message on its stream must be `notifications/subscriptions/acknowledged`,
and the JSON-RPC `result` for the listen id is reserved as the
graceful-closure marker. A server that answers the listen request with a
bare `result` is saying "acknowledged and closed in the same breath" — the
SDK settles the subscription `graceful` and rejects the pending `listen()`.

The Inspector read that rejection as an ordinary drop: eight re-lists over
roughly a minute, the badge flickering `Reconnecting…`, and a final bare
`Stream ended` that said nothing about why. Accepting the message was
never wrong — it is a well-formed graceful-closure response. The silence
was.

- `core/mcp/subscriptionAck.ts` owns the predicate. The SDK gives this
  close no code of its own — it is the same `SdkError(ConnectionClosed)`
  any pre-ack close carries — so the predicate reads the message, since
  the alternative is refusing to retry a genuinely transient drop.
- The condition is deterministic, so the stream ends on the first
  occurrence instead of spending the whole backoff run. Both routes are
  covered: the one a subscribe/unsubscribe reaches, and the one a
  reconnect reaches when an acknowledged stream drops and the server has
  since started refusing.
- A `"never-acknowledged"` status of its own, not `"ended"` — `ended`
  means an expected close. It surfaces as the thrown error's message, an
  orange "Not acknowledged" badge, and a notice in the panel.
- `subscriptions-never-acknowledged-http.json` reproduces it. It
  acknowledges the first listen that subscribes to a resource and refuses
  every one after, which is what makes the badge reachable by hand.

Closes #2097

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-27 20:49:51 -04:00
commit 8535ae0c2f
2 changed files with 612 additions and 10 deletions

View file

@ -6,6 +6,7 @@ import type {
ResourceTemplateType as ResourceTemplate,
} from "@modelcontextprotocol/client";
import type { InspectorResourceSubscription } from "@inspector/core/mcp/types.js";
import { NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE } from "@inspector/core/mcp/subscriptionAck.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import {
ResourceControls,
@ -394,6 +395,37 @@ describe("ResourceControls", () => {
expect(screen.getByText("Subscriptions (0)")).toBeInTheDocument();
expect(screen.queryByText("Listening")).not.toBeInTheDocument();
});
// A badge tooltip is not enough for this one: the server broke the listen
// contract and the user has to be told without hovering (#2097).
it("spells out a never-acknowledged close in the panel", () => {
renderWithMantine(
<ControlledResourceControls
protocolEra="modern"
subscriptionStreamState={{
active: true,
status: "never-acknowledged",
honoredUris: [],
}}
/>,
);
expect(screen.getByText("Not acknowledged")).toBeInTheDocument();
expect(
screen.getByText(NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE),
).toBeInTheDocument();
});
it("shows no such notice while the stream is healthy", () => {
renderWithMantine(
<ControlledResourceControls
protocolEra="modern"
subscriptionStreamState={activeAck}
/>,
);
expect(
screen.queryByText(NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE),
).not.toBeInTheDocument();
});
});
// A failed load is rendered above the list instead of leaving the panel

View file

@ -1,4 +1,12 @@
import { Accordion, Group, Stack, Text, TextInput, Title } from "@mantine/core";
import {
Accordion,
Alert,
Group,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import { RiArrowRightSLine } from "react-icons/ri";
import type {
@ -10,6 +18,7 @@ import type {
InspectorResourceSubscription,
ResourceSubscriptionStreamState,
} from "../../../../../../core/mcp/types.js";
import { NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE } from "../../../../../../core/mcp/subscriptionAck.js";
import { isModernEra } from "../../elements/EraBadge/eraUtils";
import { SubscriptionStreamBadge } from "../../elements/SubscriptionStreamBadge/SubscriptionStreamBadge";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
@ -33,6 +42,15 @@ const TightRow = Group.withProps({ gap: "xs", wrap: "nowrap" });
// instead of overflowing the card (#1462).
const SidebarStack = Stack.withProps({ gap: "sm", flex: 1, mih: 0 });
// The never-acknowledged close (#2097) is the one stream status whose reason has
// to be readable without hovering the badge: it is a server-conformance problem
// the user has to act on, not a lifecycle event they can wait out.
const StreamNotice = Alert.withProps({
color: "orange",
variant: "light",
title: "Subscription not acknowledged",
});
const SearchInput = TextInput.withProps({
flex: 1,
placeholder: "Search...",
@ -358,6 +376,11 @@ export function ResourceControls({
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
{streamStatus === "never-acknowledged" && (
<StreamNotice>
{NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE}
</StreamNotice>
)}
{filteredSubscriptions.map((sub) => (
<ResourceSubscribedItem
key={sub.resource.uri}