inspector/core/mcp/subscriptionAck.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

75 lines
3.6 KiB
TypeScript

import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
/**
* Recognizing the "server closed the subscription without ever acknowledging
* it" failure of a modern-era `subscriptions/listen` (#2097).
*
* A `subscriptions/listen` request is long-lived: the first message on the
* stream MUST be `notifications/subscriptions/acknowledged`, and the JSON-RPC
* `result` for the listen id is reserved as the *graceful-closure* marker
* (2026-07-28 spec, Subscriptions → Graceful Closure). A server that answers the
* listen request with a bare `result` and never acknowledges is therefore saying
* "acknowledged and closed in the same breath": the SDK settles the subscription
* with cause `"graceful"` and rejects the pending `listen()` promise.
*
* That is a *deterministic* condition — the same server answers the same way
* every time — so it must not be retried, and it is not the same event as a
* graceful shutdown of an established stream. Without the distinction the
* Inspector re-listed eight times over roughly a minute and settled on a bare
* "Stream ended" badge that said nothing about why (#2063).
*/
/**
* The distinguishing fragment of the SDK's rejection message for this case.
*
* The SDK does not give the never-acknowledged close a code of its own — it is
* an `SdkError(ConnectionClosed)`, the same code a stream closed for any other
* reason before acknowledgement carries — so the message is the only thing that
* separates them. Matching it is deliberately narrow rather than clever: the
* alternative is treating *every* pre-ack `ConnectionClosed` as deterministic
* and refusing to retry a genuinely transient drop.
*
* The live check on this string is the integration test in
* `inspectorClient-subscriptions-era.test.ts`, which drives a real server that
* answers `subscriptions/listen` with a bare result; an SDK that rephrases the
* message fails there rather than silently reverting to the eight-retry
* behavior.
*/
const NEVER_ACKNOWLEDGED_SDK_MESSAGE =
"closed the subscription gracefully before acknowledging";
/**
* The explanation shown to the user, in the UI and in the client log. Lives in
* `core/` so every client says the same thing about the same wire event.
*/
export const NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE =
"The server closed the subscription without acknowledging it. A subscriptions/listen request must first be answered with a notifications/subscriptions/acknowledged notification; a JSON-RPC result for the listen id means graceful closure. Not retrying — the server would answer the same way again.";
/**
* True when a rejected `listen()` is the never-acknowledged close above, rather
* than a transport failure, a timeout, or a pre-ack error response — each of
* which may well succeed on a retry.
*/
export function isNeverAcknowledgedSubscriptionClose(
error: unknown,
): error is SdkError {
return (
SdkError.isInstance(error) &&
error.code === SdkErrorCode.ConnectionClosed &&
error.message.includes(NEVER_ACKNOWLEDGED_SDK_MESSAGE)
);
}
/**
* The sentence to report for a failed subscribe/unsubscribe. For the
* never-acknowledged close that is the explanation above rather than the SDK's
* own wording, which names the wire event without saying what the Inspector did
* about it (nothing further) — the omission #2097 is about. Every other failure
* keeps its own message, which is the diagnostic.
*/
export function subscriptionFailureMessage(error: unknown): string {
if (isNeverAcknowledgedSubscriptionClose(error)) {
return NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE;
}
return error instanceof Error ? error.message : String(error);
}