@@ -276,7 +276,7 @@ export interface McpSubscription {
276276
277277/** @internal */
278278interface ListenStateEntry {
279- onAck : ( ( honored : SubscriptionFilter ) => void ) | undefined ;
279+ onAck : ( honored : SubscriptionFilter ) => void ;
280280 onServerCancel : ( ) => void ;
281281}
282282
@@ -1229,6 +1229,14 @@ export class Client extends Protocol<ClientContext> {
12291229 * unsolicited delivery model still applies there); no transparent shim.
12301230 */
12311231 async listen ( filter : SubscriptionFilter , options ?: RequestOptions ) : Promise < McpSubscription > {
1232+ // Connectivity is checked first so a closed instance rejects with
1233+ // NotConnected (no setup or ack timer is started); after close(),
1234+ // `_resetConnectionState` has also cleared the negotiated era, so the
1235+ // era guard alone would surface a misleading
1236+ // MethodNotSupportedByProtocolVersion.
1237+ if ( this . transport === undefined ) {
1238+ throw new SdkError ( SdkErrorCode . NotConnected , 'Not connected' ) ;
1239+ }
12321240 const negotiated = this . _negotiatedProtocolVersion ;
12331241 if ( negotiated === undefined || ! isModernProtocolVersion ( negotiated ) ) {
12341242 throw new SdkError (
@@ -1239,13 +1247,6 @@ export class Client extends Protocol<ClientContext> {
12391247 { method : 'subscriptions/listen' , protocolVersion : negotiated }
12401248 ) ;
12411249 }
1242- // Connectivity is checked here so the rejection is delivered as the
1243- // returned promise (no setup or ack timer is started) — `_parkRequest`
1244- // would otherwise throw NotConnected from inside the executor below
1245- // after the timer is armed.
1246- if ( this . transport === undefined ) {
1247- throw new SdkError ( SdkErrorCode . NotConnected , 'Not connected' ) ;
1248- }
12491250
12501251 if ( this . _onParkedNotification === undefined ) {
12511252 this . _onParkedNotification = raw => this . _listenFirstLook ( raw ) ;
@@ -1254,30 +1255,78 @@ export class Client extends Protocol<ClientContext> {
12541255 const requestAbort = new AbortController ( ) ;
12551256 const transportKind = detectProbeTransportKind ( this . transport ) ;
12561257
1257- let closed = false ;
1258- let parked ! : ReturnType < typeof this . _parkRequest > ;
1259- const close = async ( ) : Promise < void > => {
1260- if ( closed ) return ;
1261- closed = true ;
1262- this . _listenState . delete ( parked . messageId ) ;
1263- // Per-transport teardown: HTTP closes the request's SSE stream;
1264- // stdio sends notifications/cancelled referencing the listen id.
1265- if ( transportKind === 'stdio' ) {
1258+ // Explicit `opening → open → closed` state machine. Every termination
1259+ // path — ack-arrives, ack-timeout, server-cancelled, user-close,
1260+ // transport-close, send-failure — funnels through the single `settle`
1261+ // below, which clears the ack timer, unparks, transitions state, and
1262+ // resolves/rejects the opening promise exactly once. The cancelled-
1263+ // before-ack / close-before-ack hangs are impossible by construction.
1264+ let state : 'opening' | 'open' | 'closed' = 'opening' ;
1265+ let parked : ReturnType < typeof this . _parkRequest > | undefined ;
1266+ let ackTimer : ReturnType < typeof setTimeout > | undefined ;
1267+ let resolveOpening ! : ( honored : SubscriptionFilter ) => void ;
1268+ let rejectOpening ! : ( error : Error ) => void ;
1269+ const opening = new Promise < SubscriptionFilter > ( ( resolve , reject ) => {
1270+ resolveOpening = resolve ;
1271+ rejectOpening = reject ;
1272+ } ) ;
1273+
1274+ const settle = ( outcome : { ack : SubscriptionFilter } | { error : Error } | 'closed' ) : void => {
1275+ if ( state === 'closed' ) return ;
1276+ const wasOpening = state === 'opening' ;
1277+ if ( ackTimer !== undefined ) {
1278+ clearTimeout ( ackTimer ) ;
1279+ ackTimer = undefined ;
1280+ }
1281+ if ( outcome !== 'closed' && 'ack' in outcome ) {
1282+ // The single `opening → open` transition; an ack after close
1283+ // hits the `closed` guard above and is a no-op.
1284+ state = 'open' ;
1285+ resolveOpening ( outcome . ack ) ;
1286+ return ;
1287+ }
1288+ state = 'closed' ;
1289+ if ( parked !== undefined ) {
1290+ this . _listenState . delete ( parked . messageId ) ;
1291+ parked . unpark ( ) ;
1292+ }
1293+ if ( wasOpening ) {
1294+ rejectOpening (
1295+ outcome === 'closed'
1296+ ? new SdkError ( SdkErrorCode . ConnectionClosed , 'subscriptions/listen closed before the server acknowledged' )
1297+ : outcome . error
1298+ ) ;
1299+ }
1300+ } ;
1301+
1302+ // Wire-level teardown for a locally-initiated close (user close or ack
1303+ // timeout): HTTP closes the request's SSE stream; stdio sends
1304+ // notifications/cancelled referencing the listen id. Not called when
1305+ // the server already terminated (error / server-cancelled).
1306+ const wireTeardown = async ( ) : Promise < void > => {
1307+ const id = parked ?. messageId ;
1308+ if ( transportKind === 'stdio' && id !== undefined ) {
12661309 await this . transport
1267- ?. send ( { jsonrpc : '2.0' , method : 'notifications/cancelled' , params : { requestId : parked . messageId } } )
1310+ ?. send ( { jsonrpc : '2.0' , method : 'notifications/cancelled' , params : { requestId : id } } )
12681311 . catch ( ( ) => { } ) ;
12691312 } else {
12701313 requestAbort . abort ( ) ;
12711314 }
1272- parked . unpark ( ) ;
12731315 } ;
12741316
1275- const honored = await new Promise < SubscriptionFilter > ( ( resolve , reject ) => {
1276- const ackTimeout = options ?. timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC ;
1277- const timer = setTimeout ( ( ) => {
1278- void close ( ) ;
1279- reject ( new SdkError ( SdkErrorCode . RequestTimeout , 'subscriptions/listen ack timed out' , { timeout : ackTimeout } ) ) ;
1280- } , ackTimeout ) ;
1317+ const close = async ( ) : Promise < void > => {
1318+ if ( state === 'closed' ) return ;
1319+ settle ( 'closed' ) ;
1320+ await wireTeardown ( ) ;
1321+ } ;
1322+
1323+ const ackTimeout = options ?. timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC ;
1324+ ackTimer = setTimeout ( ( ) => {
1325+ settle ( { error : new SdkError ( SdkErrorCode . RequestTimeout , 'subscriptions/listen ack timed out' , { timeout : ackTimeout } ) } ) ;
1326+ void wireTeardown ( ) . catch ( ( ) => { } ) ;
1327+ } , ackTimeout ) ;
1328+
1329+ try {
12811330 // The per-subscription state is registered BEFORE the request is
12821331 // sent (`onBeforeSend`) so a synchronously-delivered ack (an
12831332 // in-process transport) cannot race the registration.
@@ -1296,31 +1345,30 @@ export class Client extends Protocol<ClientContext> {
12961345 { requestSignal : requestAbort . signal } ,
12971346 messageId => {
12981347 this . _listenState . set ( messageId , {
1299- onAck : honored => {
1300- clearTimeout ( timer ) ;
1301- resolve ( honored ) ;
1302- } ,
1303- onServerCancel : ( ) => void close ( )
1348+ onAck : honored => settle ( { ack : honored } ) ,
1349+ onServerCancel : ( ) => {
1350+ settle ( { error : new Error ( 'subscriptions/listen: server cancelled before acknowledging' ) } ) ;
1351+ }
13041352 } ) ;
13051353 }
13061354 ) ;
1355+ // A synchronously-delivered termination during `send()` (an
1356+ // in-process transport) ran `settle()` before `parked` was
1357+ // assigned — unpark now so the handler does not leak.
1358+ if ( state === 'closed' ) parked . unpark ( ) ;
13071359 // Pre-ack capacity / params rejection arrives as a JSON-RPC error
1308- // for the listen id — surfaced via terminated .
1360+ // for the listen id; transport close is delivered the same way .
13091361 void parked . terminated . then ( ( { reason, error } ) => {
1310- if ( reason === 'error' ) {
1311- clearTimeout ( timer ) ;
1312- this . _listenState . delete ( parked . messageId ) ;
1313- closed = true ;
1314- reject ( error ?? new Error ( 'subscriptions/listen failed' ) ) ;
1315- }
1362+ if ( reason === 'error' ) settle ( { error : error ?? new Error ( 'subscriptions/listen failed' ) } ) ;
13161363 } ) ;
13171364 parked . sent . catch ( error => {
1318- clearTimeout ( timer ) ;
1319- void close ( ) ;
1320- reject ( error instanceof Error ? error : new Error ( String ( error ) ) ) ;
1365+ settle ( { error : error instanceof Error ? error : new Error ( String ( error ) ) } ) ;
13211366 } ) ;
1322- } ) ;
1367+ } catch ( error ) {
1368+ settle ( { error : error instanceof Error ? error : new Error ( String ( error ) ) } ) ;
1369+ }
13231370
1371+ const honored = await opening ;
13241372 return { honoredFilter : honored , close } ;
13251373 }
13261374
@@ -1348,10 +1396,9 @@ export class Client extends Protocol<ClientContext> {
13481396 // Tolerant read: subscription id may be string or number; match by
13491397 // String() coercion against this connection's parked listen ids.
13501398 for ( const [ id , entry ] of this . _listenState ) {
1351- if ( String ( id ) === String ( subscriptionId ) && entry . onAck !== undefined ) {
1399+ if ( String ( id ) === String ( subscriptionId ) ) {
13521400 const honored = SubscriptionFilterSchema . safeParse ( params ?. notifications ?? { } ) ;
13531401 entry . onAck ( honored . success ? honored . data : { } ) ;
1354- entry . onAck = undefined ;
13551402 return 'consumed' ;
13561403 }
13571404 }
0 commit comments