Skip to content

Commit ac9446e

Browse files
fix(client): derive auto-open listen filter from configured ∩ server-advertised
`_connectNegotiated` now computes `effective = configured ∩ serverAdvertised` ONCE after discover and uses that single value for BOTH `_setupListChangedHandlers` and the auto-open `subscriptions/listen` filter. A configured-but-not-advertised type (e.g. tools configured but the server doesn't advertise tools.listChanged) is neither subscribed to nor handled, so the filter and the registered handlers stay in lockstep. When the intersection is empty, auto-open is skipped entirely. (Squash-carried: TS narrow-cast on the synchronous-termination unpark guard — control-flow narrowing does not track closure mutation.)
1 parent 7e03c89 commit ac9446e

2 files changed

Lines changed: 46 additions & 6 deletions

File tree

packages/client/src/client/client.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -862,12 +862,22 @@ export class Client extends Protocol<ClientContext> {
862862
// 2025-era unsolicited notifications, no listen needed.
863863
if (this._pendingListChangedConfig) {
864864
const config = this._pendingListChangedConfig;
865-
this._setupListChangedHandlers(config);
866865
this._pendingListChangedConfig = undefined;
866+
// Compute configured ∩ server-advertised ONCE and use that single
867+
// value for BOTH handler registration and the auto-open filter, so
868+
// a configured-but-not-advertised type is neither subscribed to
869+
// nor handled (the two stay in lockstep).
870+
const advertised = this._serverCapabilities;
871+
const effective: ListChangedHandlers = {
872+
...(config.tools && advertised?.tools?.listChanged && { tools: config.tools }),
873+
...(config.prompts && advertised?.prompts?.listChanged && { prompts: config.prompts }),
874+
...(config.resources && advertised?.resources?.listChanged && { resources: config.resources })
875+
};
876+
this._setupListChangedHandlers(effective);
867877
const filter: SubscriptionFilter = {
868-
...(config.tools && { toolsListChanged: true as const }),
869-
...(config.prompts && { promptsListChanged: true as const }),
870-
...(config.resources && { resourcesListChanged: true as const })
878+
...(effective.tools && { toolsListChanged: true as const }),
879+
...(effective.prompts && { promptsListChanged: true as const }),
880+
...(effective.resources && { resourcesListChanged: true as const })
871881
};
872882
if (Object.keys(filter).length > 0) {
873883
// A failed auto-open MUST NOT fail connect: the modern
@@ -1354,8 +1364,9 @@ export class Client extends Protocol<ClientContext> {
13541364
);
13551365
// A synchronously-delivered termination during `send()` (an
13561366
// in-process transport) ran `settle()` before `parked` was
1357-
// assigned — unpark now so the handler does not leak.
1358-
if (state === 'closed') parked.unpark();
1367+
// assigned — unpark now so the handler does not leak. (Cast: TS
1368+
// control-flow narrowing does not track closure mutation.)
1369+
if ((state as 'opening' | 'open' | 'closed') === 'closed') parked.unpark();
13591370
// Pre-ack capacity / params rejection arrives as a JSON-RPC error
13601371
// for the listen id; transport close is delivered the same way.
13611372
void parked.terminated.then(({ reason, error }) => {

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,35 @@ describe('Client.listen()', () => {
288288
expect(client.getNegotiatedProtocolVersion()).toBeUndefined();
289289
});
290290

291+
it('auto-open filter is configured ∩ server-advertised; empty intersection skips auto-open', async () => {
292+
const filters: unknown[] = [];
293+
// scriptedModern advertises tools.listChanged + prompts.listChanged but NOT resources.
294+
const { clientTx } = await scriptedModern((_id, filter) => filters.push(filter));
295+
const onChanged = () => {};
296+
const client = new Client(
297+
{ name: 'c', version: '1' },
298+
// Configures tools + resources; server advertises tools + prompts.
299+
{ versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged }, resources: { onChanged } } }
300+
);
301+
await client.connect(clientTx);
302+
// Intersection = tools only.
303+
expect(filters).toEqual([{ toolsListChanged: true }]);
304+
expect(client.autoOpenedSubscription?.honoredFilter).toEqual({ toolsListChanged: true });
305+
await client.close();
306+
307+
// Empty intersection: configures resources only; server advertises tools+prompts.
308+
const filters2: unknown[] = [];
309+
const { clientTx: clientTx2 } = await scriptedModern((_id, filter) => filters2.push(filter));
310+
const client2 = new Client(
311+
{ name: 'c', version: '1' },
312+
{ versionNegotiation: { mode: 'auto' }, listChanged: { resources: { onChanged } } }
313+
);
314+
await client2.connect(clientTx2);
315+
expect(filters2).toEqual([]);
316+
expect(client2.autoOpenedSubscription).toBeUndefined();
317+
await client2.close();
318+
});
319+
291320
it('a failed auto-open surfaces via onerror and does NOT fail connect', async () => {
292321
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
293322
serverTx.onmessage = m => {

0 commit comments

Comments
 (0)