Skip to content

Commit dfbedec

Browse files
refactor(client): centralize per-connection state reset
A new private `_resetConnectionState()` clears every per-connection field in one place — `_negotiatedProtocolVersion`, server capabilities/identity/ instructions, the auto-opened listen subscription, the listen-state map and the cached output validators. The two ad-hoc fresh-connect resets and `close()` now route through it, so a stale `autoOpenedSubscription` (or any other per-connection field) cannot survive a reconnect or outlive the connection it was opened on.
1 parent 31b0490 commit dfbedec

2 files changed

Lines changed: 47 additions & 10 deletions

File tree

packages/client/src/client/client.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,27 @@ export class Client extends Protocol<ClientContext> {
332332
/** The auto-opened subscription backing ClientOptions.listChanged on a modern connection. */
333333
private _autoOpenedSubscription?: McpSubscription;
334334

335+
/**
336+
* Clears every per-connection field in one place. Called at the start of
337+
* each fresh (non-resuming) connect and from `close()`, so a stale
338+
* negotiated era / server identity / auto-opened subscription cannot
339+
* survive a reconnect.
340+
*/
341+
private _resetConnectionState(): void {
342+
this._negotiatedProtocolVersion = undefined;
343+
this._serverCapabilities = undefined;
344+
this._serverVersion = undefined;
345+
this._instructions = undefined;
346+
this._autoOpenedSubscription = undefined;
347+
this._listenState.clear();
348+
this._cachedToolOutputValidators.clear();
349+
}
350+
351+
override async close(): Promise<void> {
352+
await super.close();
353+
this._resetConnectionState();
354+
}
355+
335356
/**
336357
* Initializes this client with the given name and version information.
337358
*/
@@ -689,15 +710,15 @@ export class Client extends Protocol<ClientContext> {
689710
}
690711
return;
691712
}
692-
// Fresh connect: the negotiated protocol version is connection state —
693-
// a value left over from a previous connection must not survive into a
694-
// new handshake. Clearing it puts the instance back in the
695-
// pre-negotiation phase, so the initialize exchange below rides the
696-
// bootstrap method pins (legacy era) instead of a dead session's era.
697-
// Without this, an instance that once negotiated a modern era could
698-
// never re-run a fresh handshake: `initialize` is physically absent
699-
// from the modern registry. (The resume branch above keeps it instead.)
700-
this._negotiatedProtocolVersion = undefined;
713+
// Fresh connect: per-connection state left over from a previous
714+
// connection must not survive into a new handshake. Clearing it puts
715+
// the instance back in the pre-negotiation phase, so the initialize
716+
// exchange below rides the bootstrap method pins (legacy era) instead
717+
// of a dead session's era. Without this, an instance that once
718+
// negotiated a modern era could never re-run a fresh handshake:
719+
// `initialize` is physically absent from the modern registry. (The
720+
// resume branch above keeps it instead.)
721+
this._resetConnectionState();
701722
await this._legacyHandshake(transport, options);
702723
}
703724

@@ -797,7 +818,7 @@ export class Client extends Protocol<ClientContext> {
797818

798819
// Fresh connect: stale connection state must not survive into a new
799820
// negotiation — every fresh negotiated connect re-runs the probe.
800-
this._negotiatedProtocolVersion = undefined;
821+
this._resetConnectionState();
801822

802823
let result: Awaited<ReturnType<typeof negotiateEra>>;
803824
try {

packages/client/test/client/listen.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,22 @@ describe('Client.listen()', () => {
195195
await client.close();
196196
});
197197

198+
it('autoOpenedSubscription is cleared on close() and on a fresh reconnect', async () => {
199+
const onChanged = () => {};
200+
const client = new Client(
201+
{ name: 'c', version: '1' },
202+
{ versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged } } }
203+
);
204+
const { clientTx } = await scriptedModern();
205+
await client.connect(clientTx);
206+
expect(client.autoOpenedSubscription).toBeDefined();
207+
await client.close();
208+
// close() clears every per-connection field.
209+
expect(client.autoOpenedSubscription).toBeUndefined();
210+
expect(client.getServerCapabilities()).toBeUndefined();
211+
expect(client.getNegotiatedProtocolVersion()).toBeUndefined();
212+
});
213+
198214
it('a failed auto-open surfaces via onerror and does NOT fail connect', async () => {
199215
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
200216
serverTx.onmessage = m => {

0 commit comments

Comments
 (0)