@@ -406,10 +406,13 @@ export interface McpSubscription {
406406 *
407407 * - `'local'` — you called {@linkcode close} (or aborted the
408408 * `RequestOptions.signal` you passed to `listen()`).
409- * - `'remote'` — the server cancelled, the stream ended, or the transport
410- * dropped. Re-listen if you still want events.
409+ * - `'graceful'` — the server ended the subscription deliberately by
410+ * sending the empty `subscriptions/listen` response (e.g. on shutdown).
411+ * - `'remote'` — the stream ended without a response, or the transport
412+ * dropped — an unexpected disconnect. Re-listen if you still want
413+ * events.
411414 */
412- readonly closed : Promise < 'local' | 'remote' > ;
415+ readonly closed : Promise < 'local' | 'graceful' | ' remote'> ;
413416}
414417
415418/** @internal */
@@ -421,7 +424,7 @@ interface ListenStateEntry {
421424 * failure, ack timeout, caller-signal abort, `_resetConnectionState` —
422425 * routes through it.
423426 */
424- settle : ( outcome : { ack : SubscriptionFilter } | { cause : 'local' | 'remote' ; error ?: Error } ) => void ;
427+ settle : ( outcome : { ack : SubscriptionFilter } | { cause : 'local' | 'graceful' | ' remote'; error ?: Error } ) => void ;
425428}
426429
427430/**
@@ -1894,12 +1897,12 @@ export class Client extends Protocol<ClientContext> {
18941897 // settle()'s `→ closed` transition; never rejects. When listen()
18951898 // itself rejects (pre-ack) there is no McpSubscription to observe it
18961899 // on — settle() resolves it anyway so nothing dangles.
1897- let resolveClosed ! : ( cause : 'local' | 'remote' ) => void ;
1898- const closed = new Promise < 'local' | 'remote' > ( resolve => {
1900+ let resolveClosed ! : ( cause : 'local' | 'graceful' | ' remote') => void ;
1901+ const closed = new Promise < 'local' | 'graceful' | ' remote'> ( resolve => {
18991902 resolveClosed = resolve ;
19001903 } ) ;
19011904
1902- const settle = ( outcome : { ack : SubscriptionFilter } | { cause : 'local' | 'remote' ; error ?: Error } ) : void => {
1905+ const settle = ( outcome : { ack : SubscriptionFilter } | { cause : 'local' | 'graceful' | ' remote'; error ?: Error } ) : void => {
19031906 if ( state === 'closed' ) return ;
19041907 const wasOpening = state === 'opening' ;
19051908 if ( ackTimer !== undefined ) {
@@ -2103,13 +2106,14 @@ export class Client extends Protocol<ClientContext> {
21032106 }
21042107
21052108 /**
2106- * Transport-level demux for `subscriptions/listen` responses. The spec
2107- * defines listen as never receiving a JSON-RPC result; a JSON-RPC ERROR
2108- * for the listen id is the server's pre-ack capacity/params rejection. A
2109- * string-id response that matches a live `_listenState` entry is consumed
2110- * here (Protocol's `_responseHandlers` map is keyed by NUMBER and never
2111- * holds a listen id, so passing a string-id response through would
2112- * surface as "unknown message ID" via `onerror`).
2109+ * Transport-level demux for `subscriptions/listen` responses. A JSON-RPC
2110+ * ERROR for the listen id is the server's pre-ack capacity/params
2111+ * rejection; a JSON-RPC RESULT for the listen id is the spec's
2112+ * `SubscriptionsListenResult` — the server's GRACEFUL-close signal (sent
2113+ * on shutdown). A string-id response that matches a live `_listenState`
2114+ * entry is consumed here (Protocol's `_responseHandlers` map is keyed by
2115+ * NUMBER and never holds a listen id, so passing a string-id response
2116+ * through would surface as "unknown message ID" via `onerror`).
21132117 */
21142118 protected override _onresponse ( response : JSONRPCResponse ) : void {
21152119 const id = response . id ;
@@ -2121,11 +2125,20 @@ export class Client extends Protocol<ClientContext> {
21212125 error : ProtocolError . fromError ( response . error . code , response . error . message , response . error . data )
21222126 } ) ;
21232127 } else {
2128+ // The empty `SubscriptionsListenResult` — the server ended
2129+ // the subscription deliberately. Handles both pre-ack and
2130+ // post-ack: while opening, settle rejects the pending listen()
2131+ // promise with a ConnectionClosed (a server that answers
2132+ // before the ack is shutting down before serving); once open,
2133+ // settle transitions to closed and `closed` resolves
2134+ // 'graceful'. Per Q8, the result body itself is not validated
2135+ // — receipt for the listen id IS the signal (foreign servers
2136+ // may omit `_meta`).
21242137 entry . settle ( {
2125- cause : 'remote ' ,
2138+ cause : 'graceful ' ,
21262139 error : new SdkError (
2127- SdkErrorCode . InvalidResult ,
2128- 'server answered subscriptions/listen with a result; expected the acknowledged notification '
2140+ SdkErrorCode . ConnectionClosed ,
2141+ 'subscriptions/listen: server closed the subscription gracefully before acknowledging '
21292142 )
21302143 } ) ;
21312144 }
0 commit comments