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:
parent
6bc3251962
commit
8535ae0c2f
2 changed files with 612 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue