inspector/clients/web/src/components/elements/SubscriptionStreamBadge/subscriptionStreamUtils.ts
cliffhall 8535ae0c2f 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>
2026-08-27 20:49:55 -04:00

59 lines
2.3 KiB
TypeScript

import type { ResourceSubscriptionStreamStatus } from "../../../../../../core/mcp/types.js";
import { NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE } from "../../../../../../core/mcp/subscriptionAck.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.`,
},
"never-acknowledged": {
color: "orange",
label: "Not acknowledged",
tooltip: `${STREAM_INTRO} ${NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE} 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];
}