diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 4efd2adac..c73506625 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -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 diff --git a/src/client/index.ts b/src/client/index.ts index 0fb6cdcf3..c8c37bb01 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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 = new Set(); private _cachedRequiredTaskTools: Set = new Set(); private _experimental?: { tasks: ExperimentalClientTasks }; + private _listChangedDebounceTimers: Map> = 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( + listType: string, + notificationSchema: { shape: { method: { value: string } } }, + options: ListChangedOptions, + fetcher: () => Promise + ): 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' }); } diff --git a/src/types.ts b/src/types.ts index 744877db1..67e072742 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 = (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 = { + /** + * 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; +}; + +/** + * 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; + /** + * Handler for prompt list changes. + */ + prompts?: ListChangedOptions; + /** + * Handler for resource list changes. + */ + resources?: ListChangedOptions; +}; + /* Logging */ /** * The severity of a log message.