Skip to content

Commit f20c57f

Browse files
committed
test: add capability checks for listChanged handlers
Add tests verifying that listChanged handlers are only activated when the server advertises the corresponding capability. Tests cover: - Handler not activated when server lacks listChanged capability - Handler activated when server advertises capability - No handlers activated when server has no listChanged capabilities - Partial capability support with some handlers activated Also defer handler setup until after initialization when server capabilities are known,
1 parent a944132 commit f20c57f

File tree

3 files changed

+239
-6
lines changed

3 files changed

+239
-6
lines changed

src/client/index.test.ts

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ListResourcesRequestSchema,
1313
ListToolsRequestSchema,
1414
ListToolsResultSchema,
15+
ListPromptsRequestSchema,
1516
CallToolRequestSchema,
1617
CallToolResultSchema,
1718
CreateMessageRequestSchema,
@@ -1558,6 +1559,225 @@ test('should handle multiple list changed handlers configured together', async (
15581559
expect(promptNotifications[0][1]).toHaveLength(2);
15591560
});
15601561

1562+
/***
1563+
* Test: Handler not activated when server doesn't advertise listChanged capability
1564+
*/
1565+
test('should not activate listChanged handler when server does not advertise capability', async () => {
1566+
const notifications: [Error | null, Tool[] | null][] = [];
1567+
1568+
// Server with tools capability but WITHOUT listChanged
1569+
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: {} } });
1570+
1571+
server.setRequestHandler(InitializeRequestSchema, async request => ({
1572+
protocolVersion: request.params.protocolVersion,
1573+
capabilities: { tools: {} }, // No listChanged: true
1574+
serverInfo: { name: 'test-server', version: '1.0.0' }
1575+
}));
1576+
1577+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
1578+
tools: [{ name: 'test-tool', inputSchema: { type: 'object' } }]
1579+
}));
1580+
1581+
// Configure listChanged handler that should NOT be activated
1582+
const client = new Client(
1583+
{ name: 'test-client', version: '1.0.0' },
1584+
{
1585+
listChanged: {
1586+
tools: {
1587+
debounceMs: 0,
1588+
onChanged: (err, tools) => {
1589+
notifications.push([err, tools]);
1590+
}
1591+
}
1592+
}
1593+
}
1594+
);
1595+
1596+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
1597+
1598+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
1599+
1600+
// Verify server doesn't have tools.listChanged capability
1601+
expect(client.getServerCapabilities()?.tools?.listChanged).toBeFalsy();
1602+
1603+
// Send a tool list changed notification manually
1604+
await server.notification({ method: 'notifications/tools/list_changed' });
1605+
await new Promise(resolve => setTimeout(resolve, 100));
1606+
1607+
// Handler should NOT have been activated because server didn't advertise listChanged
1608+
expect(notifications).toHaveLength(0);
1609+
});
1610+
1611+
/***
1612+
* Test: Handler activated when server advertises listChanged capability
1613+
*/
1614+
test('should activate listChanged handler when server advertises capability', async () => {
1615+
const notifications: [Error | null, Tool[] | null][] = [];
1616+
1617+
// Server with tools.listChanged: true capability
1618+
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } });
1619+
1620+
server.setRequestHandler(InitializeRequestSchema, async request => ({
1621+
protocolVersion: request.params.protocolVersion,
1622+
capabilities: { tools: { listChanged: true } },
1623+
serverInfo: { name: 'test-server', version: '1.0.0' }
1624+
}));
1625+
1626+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
1627+
tools: [{ name: 'test-tool', inputSchema: { type: 'object' } }]
1628+
}));
1629+
1630+
// Configure listChanged handler that SHOULD be activated
1631+
const client = new Client(
1632+
{ name: 'test-client', version: '1.0.0' },
1633+
{
1634+
listChanged: {
1635+
tools: {
1636+
debounceMs: 0,
1637+
onChanged: (err, tools) => {
1638+
notifications.push([err, tools]);
1639+
}
1640+
}
1641+
}
1642+
}
1643+
);
1644+
1645+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
1646+
1647+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
1648+
1649+
// Verify server has tools.listChanged capability
1650+
expect(client.getServerCapabilities()?.tools?.listChanged).toBe(true);
1651+
1652+
// Send a tool list changed notification
1653+
await server.notification({ method: 'notifications/tools/list_changed' });
1654+
await new Promise(resolve => setTimeout(resolve, 100));
1655+
1656+
// Handler SHOULD have been called
1657+
expect(notifications).toHaveLength(1);
1658+
expect(notifications[0][0]).toBeNull();
1659+
expect(notifications[0][1]).toHaveLength(1);
1660+
});
1661+
1662+
/***
1663+
* Test: No handlers activated when server has no listChanged capabilities
1664+
*/
1665+
test('should not activate any handlers when server has no listChanged capabilities', async () => {
1666+
const toolNotifications: [Error | null, Tool[] | null][] = [];
1667+
const promptNotifications: [Error | null, Prompt[] | null][] = [];
1668+
const resourceNotifications: [Error | null, Resource[] | null][] = [];
1669+
1670+
// Server with capabilities but NO listChanged for any
1671+
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: {}, prompts: {}, resources: {} } });
1672+
1673+
server.setRequestHandler(InitializeRequestSchema, async request => ({
1674+
protocolVersion: request.params.protocolVersion,
1675+
capabilities: { tools: {}, prompts: {}, resources: {} },
1676+
serverInfo: { name: 'test-server', version: '1.0.0' }
1677+
}));
1678+
1679+
// Configure listChanged handlers for all three types
1680+
const client = new Client(
1681+
{ name: 'test-client', version: '1.0.0' },
1682+
{
1683+
listChanged: {
1684+
tools: {
1685+
debounceMs: 0,
1686+
onChanged: (err, tools) => toolNotifications.push([err, tools])
1687+
},
1688+
prompts: {
1689+
debounceMs: 0,
1690+
onChanged: (err, prompts) => promptNotifications.push([err, prompts])
1691+
},
1692+
resources: {
1693+
debounceMs: 0,
1694+
onChanged: (err, resources) => resourceNotifications.push([err, resources])
1695+
}
1696+
}
1697+
}
1698+
);
1699+
1700+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
1701+
1702+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
1703+
1704+
// Verify server has no listChanged capabilities
1705+
const caps = client.getServerCapabilities();
1706+
expect(caps?.tools?.listChanged).toBeFalsy();
1707+
expect(caps?.prompts?.listChanged).toBeFalsy();
1708+
expect(caps?.resources?.listChanged).toBeFalsy();
1709+
1710+
// Send notifications for all three types
1711+
await server.notification({ method: 'notifications/tools/list_changed' });
1712+
await server.notification({ method: 'notifications/prompts/list_changed' });
1713+
await server.notification({ method: 'notifications/resources/list_changed' });
1714+
await new Promise(resolve => setTimeout(resolve, 100));
1715+
1716+
// No handlers should have been activated
1717+
expect(toolNotifications).toHaveLength(0);
1718+
expect(promptNotifications).toHaveLength(0);
1719+
expect(resourceNotifications).toHaveLength(0);
1720+
});
1721+
1722+
/***
1723+
* Test: Partial capability support - some handlers activated, others not
1724+
*/
1725+
test('should handle partial listChanged capability support', async () => {
1726+
const toolNotifications: [Error | null, Tool[] | null][] = [];
1727+
const promptNotifications: [Error | null, Prompt[] | null][] = [];
1728+
1729+
// Server with tools.listChanged: true but prompts without listChanged
1730+
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: { listChanged: true }, prompts: {} } });
1731+
1732+
server.setRequestHandler(InitializeRequestSchema, async request => ({
1733+
protocolVersion: request.params.protocolVersion,
1734+
capabilities: { tools: { listChanged: true }, prompts: {} },
1735+
serverInfo: { name: 'test-server', version: '1.0.0' }
1736+
}));
1737+
1738+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
1739+
tools: [{ name: 'tool-1', inputSchema: { type: 'object' } }]
1740+
}));
1741+
1742+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
1743+
prompts: [{ name: 'prompt-1' }]
1744+
}));
1745+
1746+
const client = new Client(
1747+
{ name: 'test-client', version: '1.0.0' },
1748+
{
1749+
listChanged: {
1750+
tools: {
1751+
debounceMs: 0,
1752+
onChanged: (err, tools) => toolNotifications.push([err, tools])
1753+
},
1754+
prompts: {
1755+
debounceMs: 0,
1756+
onChanged: (err, prompts) => promptNotifications.push([err, prompts])
1757+
}
1758+
}
1759+
}
1760+
);
1761+
1762+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
1763+
1764+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
1765+
1766+
// Verify capability state
1767+
expect(client.getServerCapabilities()?.tools?.listChanged).toBe(true);
1768+
expect(client.getServerCapabilities()?.prompts?.listChanged).toBeFalsy();
1769+
1770+
// Send notifications for both
1771+
await server.notification({ method: 'notifications/tools/list_changed' });
1772+
await server.notification({ method: 'notifications/prompts/list_changed' });
1773+
await new Promise(resolve => setTimeout(resolve, 100));
1774+
1775+
// Tools handler should have been called
1776+
expect(toolNotifications).toHaveLength(1);
1777+
// Prompts handler should NOT have been called (no prompts.listChanged)
1778+
expect(promptNotifications).toHaveLength(0);
1779+
});
1780+
15611781
describe('outputSchema validation', () => {
15621782
/***
15631783
* Test: Validate structuredContent Against outputSchema

src/client/index.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ export class Client<
239239
private _cachedRequiredTaskTools: Set<string> = new Set();
240240
private _experimental?: { tasks: ExperimentalClientTasks<RequestT, NotificationT, ResultT> };
241241
private _listChangedDebounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
242+
private _pendingListChangedConfig?: ListChangedHandlers;
242243

243244
/**
244245
* Initializes this client with the given name and version information.
@@ -251,32 +252,34 @@ export class Client<
251252
this._capabilities = options?.capabilities ?? {};
252253
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator();
253254

254-
// Set up list changed handlers if configured
255+
// Store list changed config for setup after connection (when we know server capabilities)
255256
if (options?.listChanged) {
256-
this._setupListChangedHandlers(options.listChanged);
257+
this._pendingListChangedConfig = options.listChanged;
257258
}
258259
}
259260

260261
/**
261-
* Set up handlers for list changed notifications based on config.
262+
* Set up handlers for list changed notifications based on config and server capabilities.
263+
* This should only be called after initialization when server capabilities are known.
264+
* Handlers are silently skipped if the server doesn't advertise the corresponding listChanged capability.
262265
* @internal
263266
*/
264267
private _setupListChangedHandlers(config: ListChangedHandlers): void {
265-
if (config.tools) {
268+
if (config.tools && this._serverCapabilities?.tools?.listChanged) {
266269
this._setupListChangedHandler('tools', ToolListChangedNotificationSchema, config.tools, async () => {
267270
const result = await this.listTools();
268271
return result.tools;
269272
});
270273
}
271274

272-
if (config.prompts) {
275+
if (config.prompts && this._serverCapabilities?.prompts?.listChanged) {
273276
this._setupListChangedHandler('prompts', PromptListChangedNotificationSchema, config.prompts, async () => {
274277
const result = await this.listPrompts();
275278
return result.prompts;
276279
});
277280
}
278281

279-
if (config.resources) {
282+
if (config.resources && this._serverCapabilities?.resources?.listChanged) {
280283
this._setupListChangedHandler('resources', ResourceListChangedNotificationSchema, config.resources, async () => {
281284
const result = await this.listResources();
282285
return result.resources;
@@ -509,6 +512,12 @@ export class Client<
509512
await this.notification({
510513
method: 'notifications/initialized'
511514
});
515+
516+
// Set up list changed handlers now that we know server capabilities
517+
if (this._pendingListChangedConfig) {
518+
this._setupListChangedHandlers(this._pendingListChangedConfig);
519+
this._pendingListChangedConfig = undefined;
520+
}
512521
} catch (error) {
513522
// Disconnect if initialization fails.
514523
void this.close();

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1490,6 +1490,10 @@ export type ListChangedOptions<T> = {
14901490
*
14911491
* Use this to configure handlers for tools, prompts, and resources list changes
14921492
* when creating a client.
1493+
*
1494+
* Note: Handlers are only activated if the server advertises the corresponding
1495+
* `listChanged` capability (e.g., `tools.listChanged: true`). If the server
1496+
* doesn't advertise this capability, the handler will not be set up.
14931497
*/
14941498
export type ListChangedHandlers = {
14951499
/**

0 commit comments

Comments
 (0)