diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 4efd2adac..9ef3d1c4a 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -20,7 +20,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 +1232,332 @@ 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); +}); + describe('outputSchema validation', () => { /*** * Test: Validate structuredContent Against outputSchema diff --git a/src/client/index.ts b/src/client/index.ts index 0fb6cdcf3..2451d3d24 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,7 @@ export class Client< private _cachedKnownTaskTools: Set = new Set(); private _cachedRequiredTaskTools: Set = new Set(); private _experimental?: { tasks: ExperimentalClientTasks }; + private _listChangedDebounceTimers: Map> = new Map(); /** * Initializes this client with the given name and version information. @@ -215,6 +250,38 @@ export class Client< super(options); this._capabilities = options?.capabilities ?? {}; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator(); + + // Set up list changed handlers if configured + if (options?.listChanged) { + this._setupListChangedHandlers(options.listChanged); + } + } + + /** + * Set up handlers for list changed notifications based on config. + * @internal + */ + private _setupListChangedHandlers(config: ListChangedHandlers): void { + if (config.tools) { + this._setupListChangedHandler('tools', ToolListChangedNotificationSchema, config.tools, async () => { + const result = await this.listTools(); + return result.tools; + }); + } + + if (config.prompts) { + this._setupListChangedHandler('prompts', PromptListChangedNotificationSchema, config.prompts, async () => { + const result = await this.listPrompts(); + return result.prompts; + }); + } + + if (config.resources) { + this._setupListChangedHandler('resources', ResourceListChangedNotificationSchema, config.resources, async () => { + const result = await this.listResources(); + return result.resources; + }); + } } /** @@ -757,6 +824,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 03acc3e6a..e0838b5e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1381,6 +1381,82 @@ 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. + */ +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.