list changed handlers on client constructor (#1206)

Co-authored-by: ChipGPT <chip@ignission.io>
Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
This commit is contained in:
Matt 2025-12-04 19:08:36 +00:00 • committed by GitHub
commit b0aac584fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 767 additions and 2 deletions

View file

@ -12,6 +12,7 @@ import {
ListResourcesRequestSchema,
ListToolsRequestSchema,
ListToolsResultSchema,
ListPromptsRequestSchema,
CallToolRequestSchema,
CallToolResultSchema,
CreateMessageRequestSchema,
@ -20,7 +21,10 @@ import {
ListRootsRequestSchema,
ErrorCode,
McpError,
CreateTaskResultSchema
CreateTaskResultSchema,
Tool,
Prompt,
Resource
} from '../types.js';
import { Transport } from '../shared/transport.js';
import { Server } from '../server/index.js';
@ -1229,6 +1233,551 @@ test('should handle request timeout', async () => {
});
});
/***
* Test: Handle Tool List Changed Notifications with Auto Refresh
*/
test('should handle tool list changed notification with auto refresh', async () => {
// List changed notifications
const notifications: [Error | null, Tool[] | null][] = [];
const server = new McpServer({
name: 'test-server',
version: '1.0.0'
});
// Register initial tool to enable the tools capability
server.registerTool(
'initial-tool',
{
description: 'Initial tool'
},
async () => ({ content: [] })
);
// Configure listChanged handler in constructor
const client = new Client(
{
name: 'test-client',
version: '1.0.0'
},
{
listChanged: {
tools: {
onChanged: (err, tools) => {
notifications.push([err, tools]);
}
}
}
}
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
const result1 = await client.listTools();
expect(result1.tools).toHaveLength(1);
// Register another tool - this triggers listChanged notification
server.registerTool(
'test-tool',
{
description: 'A test tool'
},
async () => ({ content: [] })
);
// Wait for the debounced notifications to be processed
await new Promise(resolve => setTimeout(resolve, 1000));
// Should be 1 notification with 2 tools because autoRefresh is true
expect(notifications).toHaveLength(1);
expect(notifications[0][0]).toBeNull();
expect(notifications[0][1]).toHaveLength(2);
expect(notifications[0][1]?.[1].name).toBe('test-tool');
});
/***
* Test: Handle Tool List Changed Notifications with Manual Refresh
*/
test('should handle tool list changed notification with manual refresh', async () => {
// List changed notifications
const notifications: [Error | null, Tool[] | null][] = [];
const server = new McpServer({
name: 'test-server',
version: '1.0.0'
});
// Register initial tool to enable the tools capability
server.registerTool('initial-tool', {}, async () => ({ content: [] }));
// Configure listChanged handler with manual refresh (autoRefresh: false)
const client = new Client(
{
name: 'test-client',
version: '1.0.0'
},
{
listChanged: {
tools: {
autoRefresh: false,
debounceMs: 0,
onChanged: (err, tools) => {
notifications.push([err, tools]);
}
}
}
}
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
const result1 = await client.listTools();
expect(result1.tools).toHaveLength(1);
// Register another tool - this triggers listChanged notification
server.registerTool(
'test-tool',
{
description: 'A test tool'
},
async () => ({ content: [] })
);
// Wait for the notifications to be processed (no debounce)
await new Promise(resolve => setTimeout(resolve, 100));
// Should be 1 notification with no tool data because autoRefresh is false
expect(notifications).toHaveLength(1);
expect(notifications[0][0]).toBeNull();
expect(notifications[0][1]).toBeNull();
});
/***
* Test: Handle Prompt List Changed Notifications
*/
test('should handle prompt list changed notification with auto refresh', async () => {
const notifications: [Error | null, Prompt[] | null][] = [];
const server = new McpServer({
name: 'test-server',
version: '1.0.0'
});
// Register initial prompt to enable the prompts capability
server.registerPrompt(
'initial-prompt',
{
description: 'Initial prompt'
},
async () => ({
messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }]
})
);
// Configure listChanged handler in constructor
const client = new Client(
{
name: 'test-client',
version: '1.0.0'
},
{
listChanged: {
prompts: {
onChanged: (err, prompts) => {
notifications.push([err, prompts]);
}
}
}
}
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
const result1 = await client.listPrompts();
expect(result1.prompts).toHaveLength(1);
// Register another prompt - this triggers listChanged notification
server.registerPrompt('test-prompt', { description: 'A test prompt' }, async () => ({
messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }]
}));
// Wait for the debounced notifications to be processed
await new Promise(resolve => setTimeout(resolve, 1000));
// Should be 1 notification with 2 prompts because autoRefresh is true
expect(notifications).toHaveLength(1);
expect(notifications[0][0]).toBeNull();
expect(notifications[0][1]).toHaveLength(2);
expect(notifications[0][1]?.[1].name).toBe('test-prompt');
});
/***
* Test: Handle Resource List Changed Notifications
*/
test('should handle resource list changed notification with auto refresh', async () => {
const notifications: [Error | null, Resource[] | null][] = [];
const server = new McpServer({
name: 'test-server',
version: '1.0.0'
});
// Register initial resource to enable the resources capability
server.registerResource('initial-resource', 'file:///initial.txt', {}, async () => ({
contents: [{ uri: 'file:///initial.txt', text: 'Hello' }]
}));
// Configure listChanged handler in constructor
const client = new Client(
{
name: 'test-client',
version: '1.0.0'
},
{
listChanged: {
resources: {
onChanged: (err, resources) => {
notifications.push([err, resources]);
}
}
}
}
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
const result1 = await client.listResources();
expect(result1.resources).toHaveLength(1);
// Register another resource - this triggers listChanged notification
server.registerResource('test-resource', 'file:///test.txt', {}, async () => ({
contents: [{ uri: 'file:///test.txt', text: 'Hello' }]
}));
// Wait for the debounced notifications to be processed
await new Promise(resolve => setTimeout(resolve, 1000));
// Should be 1 notification with 2 resources because autoRefresh is true
expect(notifications).toHaveLength(1);
expect(notifications[0][0]).toBeNull();
expect(notifications[0][1]).toHaveLength(2);
expect(notifications[0][1]?.[1].name).toBe('test-resource');
});
/***
* Test: Handle Multiple List Changed Handlers
*/
test('should handle multiple list changed handlers configured together', async () => {
const toolNotifications: [Error | null, Tool[] | null][] = [];
const promptNotifications: [Error | null, Prompt[] | null][] = [];
const server = new McpServer({
name: 'test-server',
version: '1.0.0'
});
// Register initial tool and prompt to enable capabilities
server.registerTool(
'tool-1',
{
description: 'Tool 1'
},
async () => ({ content: [] })
);
server.registerPrompt(
'prompt-1',
{
description: 'Prompt 1'
},
async () => ({
messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }]
})
);
// Configure multiple listChanged handlers in constructor
const client = new Client(
{
name: 'test-client',
version: '1.0.0'
},
{
listChanged: {
tools: {
debounceMs: 0,
onChanged: (err, tools) => {
toolNotifications.push([err, tools]);
}
},
prompts: {
debounceMs: 0,
onChanged: (err, prompts) => {
promptNotifications.push([err, prompts]);
}
}
}
}
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
// Register another tool and prompt to trigger notifications
server.registerTool(
'tool-2',
{
description: 'Tool 2'
},
async () => ({ content: [] })
);
server.registerPrompt(
'prompt-2',
{
description: 'Prompt 2'
},
async () => ({
messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }]
})
);
// Wait for notifications to be processed
await new Promise(resolve => setTimeout(resolve, 100));
// Both handlers should have received their respective notifications
expect(toolNotifications).toHaveLength(1);
expect(toolNotifications[0][1]).toHaveLength(2);
expect(promptNotifications).toHaveLength(1);
expect(promptNotifications[0][1]).toHaveLength(2);
});
/***
* Test: Handler not activated when server doesn't advertise listChanged capability
*/
test('should not activate listChanged handler when server does not advertise capability', async () => {
const notifications: [Error | null, Tool[] | null][] = [];
// Server with tools capability but WITHOUT listChanged
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: {} } });
server.setRequestHandler(InitializeRequestSchema, async request => ({
protocolVersion: request.params.protocolVersion,
capabilities: { tools: {} }, // No listChanged: true
serverInfo: { name: 'test-server', version: '1.0.0' }
}));
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{ name: 'test-tool', inputSchema: { type: 'object' } }]
}));
// Configure listChanged handler that should NOT be activated
const client = new Client(
{ name: 'test-client', version: '1.0.0' },
{
listChanged: {
tools: {
debounceMs: 0,
onChanged: (err, tools) => {
notifications.push([err, tools]);
}
}
}
}
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
// Verify server doesn't have tools.listChanged capability
expect(client.getServerCapabilities()?.tools?.listChanged).toBeFalsy();
// Send a tool list changed notification manually
await server.notification({ method: 'notifications/tools/list_changed' });
await new Promise(resolve => setTimeout(resolve, 100));
// Handler should NOT have been activated because server didn't advertise listChanged
expect(notifications).toHaveLength(0);
});
/***
* Test: Handler activated when server advertises listChanged capability
*/
test('should activate listChanged handler when server advertises capability', async () => {
const notifications: [Error | null, Tool[] | null][] = [];
// Server with tools.listChanged: true capability
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } });
server.setRequestHandler(InitializeRequestSchema, async request => ({
protocolVersion: request.params.protocolVersion,
capabilities: { tools: { listChanged: true } },
serverInfo: { name: 'test-server', version: '1.0.0' }
}));
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{ name: 'test-tool', inputSchema: { type: 'object' } }]
}));
// Configure listChanged handler that SHOULD be activated
const client = new Client(
{ name: 'test-client', version: '1.0.0' },
{
listChanged: {
tools: {
debounceMs: 0,
onChanged: (err, tools) => {
notifications.push([err, tools]);
}
}
}
}
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
// Verify server has tools.listChanged capability
expect(client.getServerCapabilities()?.tools?.listChanged).toBe(true);
// Send a tool list changed notification
await server.notification({ method: 'notifications/tools/list_changed' });
await new Promise(resolve => setTimeout(resolve, 100));
// Handler SHOULD have been called
expect(notifications).toHaveLength(1);
expect(notifications[0][0]).toBeNull();
expect(notifications[0][1]).toHaveLength(1);
});
/***
* Test: No handlers activated when server has no listChanged capabilities
*/
test('should not activate any handlers when server has no listChanged capabilities', async () => {
const toolNotifications: [Error | null, Tool[] | null][] = [];
const promptNotifications: [Error | null, Prompt[] | null][] = [];
const resourceNotifications: [Error | null, Resource[] | null][] = [];
// Server with capabilities but NO listChanged for any
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: {}, prompts: {}, resources: {} } });
server.setRequestHandler(InitializeRequestSchema, async request => ({
protocolVersion: request.params.protocolVersion,
capabilities: { tools: {}, prompts: {}, resources: {} },
serverInfo: { name: 'test-server', version: '1.0.0' }
}));
// Configure listChanged handlers for all three types
const client = new Client(
{ name: 'test-client', version: '1.0.0' },
{
listChanged: {
tools: {
debounceMs: 0,
onChanged: (err, tools) => toolNotifications.push([err, tools])
},
prompts: {
debounceMs: 0,
onChanged: (err, prompts) => promptNotifications.push([err, prompts])
},
resources: {
debounceMs: 0,
onChanged: (err, resources) => resourceNotifications.push([err, resources])
}
}
}
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
// Verify server has no listChanged capabilities
const caps = client.getServerCapabilities();
expect(caps?.tools?.listChanged).toBeFalsy();
expect(caps?.prompts?.listChanged).toBeFalsy();
expect(caps?.resources?.listChanged).toBeFalsy();
// Send notifications for all three types
await server.notification({ method: 'notifications/tools/list_changed' });
await server.notification({ method: 'notifications/prompts/list_changed' });
await server.notification({ method: 'notifications/resources/list_changed' });
await new Promise(resolve => setTimeout(resolve, 100));
// No handlers should have been activated
expect(toolNotifications).toHaveLength(0);
expect(promptNotifications).toHaveLength(0);
expect(resourceNotifications).toHaveLength(0);
});
/***
* Test: Partial capability support - some handlers activated, others not
*/
test('should handle partial listChanged capability support', async () => {
const toolNotifications: [Error | null, Tool[] | null][] = [];
const promptNotifications: [Error | null, Prompt[] | null][] = [];
// Server with tools.listChanged: true but prompts without listChanged
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: { listChanged: true }, prompts: {} } });
server.setRequestHandler(InitializeRequestSchema, async request => ({
protocolVersion: request.params.protocolVersion,
capabilities: { tools: { listChanged: true }, prompts: {} },
serverInfo: { name: 'test-server', version: '1.0.0' }
}));
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{ name: 'tool-1', inputSchema: { type: 'object' } }]
}));
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
prompts: [{ name: 'prompt-1' }]
}));
const client = new Client(
{ name: 'test-client', version: '1.0.0' },
{
listChanged: {
tools: {
debounceMs: 0,
onChanged: (err, tools) => toolNotifications.push([err, tools])
},
prompts: {
debounceMs: 0,
onChanged: (err, prompts) => promptNotifications.push([err, prompts])
}
}
}
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
// Verify capability state
expect(client.getServerCapabilities()?.tools?.listChanged).toBe(true);
expect(client.getServerCapabilities()?.prompts?.listChanged).toBeFalsy();
// Send notifications for both
await server.notification({ method: 'notifications/tools/list_changed' });
await server.notification({ method: 'notifications/prompts/list_changed' });
await new Promise(resolve => setTimeout(resolve, 100));
// Tools handler should have been called
expect(toolNotifications).toHaveLength(1);
// Prompts handler should NOT have been called (no prompts.listChanged)
expect(promptNotifications).toHaveLength(0);
});
describe('outputSchema validation', () => {
/***
* Test: Validate structuredContent Against outputSchema

View file

@ -42,7 +42,13 @@ import {
ElicitRequestSchema,
CreateTaskResultSchema,
CreateMessageRequestSchema,
CreateMessageResultSchema
CreateMessageResultSchema,
ToolListChangedNotificationSchema,
PromptListChangedNotificationSchema,
ResourceListChangedNotificationSchema,
ListChangedOptions,
ListChangedOptionsBaseSchema,
type ListChangedHandlers
} from '../types.js';
import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js';
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js';
@ -163,6 +169,34 @@ export type ClientOptions = ProtocolOptions & {
* ```
*/
jsonSchemaValidator?: jsonSchemaValidator;
/**
* Configure handlers for list changed notifications (tools, prompts, resources).
*
* @example
* ```typescript
* const client = new Client(
* { name: 'my-client', version: '1.0.0' },
* {
* listChanged: {
* tools: {
* onChanged: (error, tools) => {
* if (error) {
* console.error('Failed to refresh tools:', error);
* return;
* }
* console.log('Tools updated:', tools);
* }
* },
* prompts: {
* onChanged: (error, prompts) => console.log('Prompts updated:', prompts)
* }
* }
* }
* );
* ```
*/
listChanged?: ListChangedHandlers;
};
/**
@ -204,6 +238,8 @@ export class Client<
private _cachedKnownTaskTools: Set<string> = new Set();
private _cachedRequiredTaskTools: Set<string> = new Set();
private _experimental?: { tasks: ExperimentalClientTasks<RequestT, NotificationT, ResultT> };
private _listChangedDebounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
private _pendingListChangedConfig?: ListChangedHandlers;
/**
* Initializes this client with the given name and version information.
@ -215,6 +251,40 @@ export class Client<
super(options);
this._capabilities = options?.capabilities ?? {};
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator();
// Store list changed config for setup after connection (when we know server capabilities)
if (options?.listChanged) {
this._pendingListChangedConfig = options.listChanged;
}
}
/**
* Set up handlers for list changed notifications based on config and server capabilities.
* This should only be called after initialization when server capabilities are known.
* Handlers are silently skipped if the server doesn't advertise the corresponding listChanged capability.
* @internal
*/
private _setupListChangedHandlers(config: ListChangedHandlers): void {
if (config.tools && this._serverCapabilities?.tools?.listChanged) {
this._setupListChangedHandler('tools', ToolListChangedNotificationSchema, config.tools, async () => {
const result = await this.listTools();
return result.tools;
});
}
if (config.prompts && this._serverCapabilities?.prompts?.listChanged) {
this._setupListChangedHandler('prompts', PromptListChangedNotificationSchema, config.prompts, async () => {
const result = await this.listPrompts();
return result.prompts;
});
}
if (config.resources && this._serverCapabilities?.resources?.listChanged) {
this._setupListChangedHandler('resources', ResourceListChangedNotificationSchema, config.resources, async () => {
const result = await this.listResources();
return result.resources;
});
}
}
/**
@ -442,6 +512,12 @@ export class Client<
await this.notification({
method: 'notifications/initialized'
});
// Set up list changed handlers now that we know server capabilities
if (this._pendingListChangedConfig) {
this._setupListChangedHandlers(this._pendingListChangedConfig);
this._pendingListChangedConfig = undefined;
}
} catch (error) {
// Disconnect if initialization fails.
void this.close();
@ -757,6 +833,66 @@ export class Client<
return result;
}
/**
* Set up a single list changed handler.
* @internal
*/
private _setupListChangedHandler<T>(
listType: string,
notificationSchema: { shape: { method: { value: string } } },
options: ListChangedOptions<T>,
fetcher: () => Promise<T[]>
): void {
// Validate options using Zod schema (validates autoRefresh and debounceMs)
const parseResult = ListChangedOptionsBaseSchema.safeParse(options);
if (!parseResult.success) {
throw new Error(`Invalid ${listType} listChanged options: ${parseResult.error.message}`);
}
// Validate callback
if (typeof options.onChanged !== 'function') {
throw new Error(`Invalid ${listType} listChanged options: onChanged must be a function`);
}
const { autoRefresh, debounceMs } = parseResult.data;
const { onChanged } = options;
const refresh = async () => {
if (!autoRefresh) {
onChanged(null, null);
return;
}
try {
const items = await fetcher();
onChanged(null, items);
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
onChanged(error, null);
}
};
const handler = () => {
if (debounceMs) {
// Clear any pending debounce timer for this list type
const existingTimer = this._listChangedDebounceTimers.get(listType);
if (existingTimer) {
clearTimeout(existingTimer);
}
// Set up debounced refresh
const timer = setTimeout(refresh, debounceMs);
this._listChangedDebounceTimers.set(listType, timer);
} else {
// No debounce, refresh immediately
refresh();
}
};
// Register notification handler
this.setNotificationHandler(notificationSchema as AnyObjectSchema, handler);
}
async sendRootsListChanged() {
return this.notification({ method: 'notifications/roots/list_changed' });
}

View file

@ -1430,6 +1430,86 @@ export const ToolListChangedNotificationSchema = NotificationSchema.extend({
method: z.literal('notifications/tools/list_changed')
});
/**
* Callback type for list changed notifications.
*/
export type ListChangedCallback<T> = (error: Error | null, items: T[] | null) => void;
/**
* Base schema for list changed subscription options (without callback).
* Used internally for Zod validation of autoRefresh and debounceMs.
*/
export const ListChangedOptionsBaseSchema = z.object({
/**
* If true, the list will be refreshed automatically when a list changed notification is received.
* The callback will be called with the updated list.
*
* If false, the callback will be called with null items, allowing manual refresh.
*
* @default true
*/
autoRefresh: z.boolean().default(true),
/**
* Debounce time in milliseconds for list changed notification processing.
*
* Multiple notifications received within this timeframe will only trigger one refresh.
* Set to 0 to disable debouncing.
*
* @default 300
*/
debounceMs: z.number().int().nonnegative().default(300)
});
/**
* Options for subscribing to list changed notifications.
*
* @typeParam T - The type of items in the list (Tool, Prompt, or Resource)
*/
export type ListChangedOptions<T> = {
/**
* If true, the list will be refreshed automatically when a list changed notification is received.
* @default true
*/
autoRefresh?: boolean;
/**
* Debounce time in milliseconds. Set to 0 to disable.
* @default 300
*/
debounceMs?: number;
/**
* Callback invoked when the list changes.
*
* If autoRefresh is true, items contains the updated list.
* If autoRefresh is false, items is null (caller should refresh manually).
*/
onChanged: ListChangedCallback<T>;
};
/**
* Configuration for list changed notification handlers.
*
* Use this to configure handlers for tools, prompts, and resources list changes
* when creating a client.
*
* Note: Handlers are only activated if the server advertises the corresponding
* `listChanged` capability (e.g., `tools.listChanged: true`). If the server
* doesn't advertise this capability, the handler will not be set up.
*/
export type ListChangedHandlers = {
/**
* Handler for tool list changes.
*/
tools?: ListChangedOptions<Tool>;
/**
* Handler for prompt list changes.
*/
prompts?: ListChangedOptions<Prompt>;
/**
* Handler for resource list changes.
*/
resources?: ListChangedOptions<Resource>;
};
/* Logging */
/**
* The severity of a log message.