diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 912abaac3..0a9c89394 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -16,7 +16,8 @@ import { CreateMessageRequestSchema, ElicitRequestSchema, ListRootsRequestSchema, - ErrorCode + ErrorCode, + Tool } from '../types.js'; import { Transport } from '../shared/transport.js'; import { Server } from '../server/index.js'; @@ -770,6 +771,179 @@ 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 Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: { + listChanged: true + } + } + } + ); + + // Set up server handlers + 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: [] + })); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + toolListChangedOptions: { + onToolListChanged: (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(0); + + // Update the tools list + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + } + // No outputSchema + } + ] + })); + await server.sendToolListChanged(); + + // Wait for the debounced notifications to be processed + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Should be 1 notification with 1 tool because autoRefresh is true + expect(notifications).toHaveLength(1); + expect(notifications[0][0]).toBeNull(); + expect(notifications[0][1]).toHaveLength(1); + expect(notifications[0][1]?.[0].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 Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: { + listChanged: true + } + } + } + ); + + // Set up server handlers + 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: [] + })); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + toolListChangedOptions: { + autoRefresh: false, + debounceMs: 0, + onToolListChanged: (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(0); + + // Update the tools list + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + } + // No outputSchema + } + ] + })); + await server.sendToolListChanged(); + + // Wait for the debounced notifications to be processed + await new Promise(resolve => setTimeout(resolve, 1000)); + + // 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(); +}); + describe('outputSchema validation', () => { /*** * Test: Validate structuredContent Against outputSchema diff --git a/src/client/index.ts b/src/client/index.ts index 4dc6a20d9..ba8c63065 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -38,7 +38,10 @@ import { type Tool, type UnsubscribeRequest, ElicitResultSchema, - ElicitRequestSchema + ElicitRequestSchema, + ToolListChangedNotificationSchema, + ToolListChangedOptions, + ToolListChangedOptionsSchema } from '../types.js'; import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js'; @@ -108,6 +111,44 @@ export type ClientOptions = ProtocolOptions & { * ``` */ jsonSchemaValidator?: jsonSchemaValidator; + + /** + * Configure automatic refresh behavior for tool list changed notifications + * + * Here's an example of how to get the updated tool list when the tool list changed notification is received: + * + * @example + * ```typescript + * { + * onToolListChanged: (err, tools) => { + * if (err) { + * console.error('Failed to refresh tool list:', err); + * return; + * } + * // Use the updated tool list + * console.log('Tool list changed:', tools); + * } + * } + * ``` + * + * Here is an example of how to manually refresh the tool list when the tool list changed notification is received: + * + * @example + * ```typescript + * { + * autoRefresh: false, + * debounceMs: 0, + * onToolListChanged: (err, tools) => { + * // err is always null when autoRefresh is false + * + * // Manually refresh the tool list + * const result = await this.listTools(); + * console.log('Tool list changed:', result.tools); + * } + * } + * ``` + */ + toolListChangedOptions?: ToolListChangedOptions; }; /** @@ -146,6 +187,8 @@ export class Client< private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; private _cachedToolOutputValidators: Map> = new Map(); + private _toolListChangedOptions?: ToolListChangedOptions; + private _toolListChangedDebounceTimer?: ReturnType; /** * Initializes this client with the given name and version information. @@ -157,6 +200,9 @@ export class Client< super(options); this._capabilities = options?.capabilities ?? {}; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator(); + + // Set up tool list changed options + this.setToolListChangedOptions(options?.toolListChangedOptions || null); } /** @@ -523,6 +569,73 @@ export class Client< return result; } + /** + * Updates the tool list changed options + * + * Set to null to disable tool list changed notifications + */ + public setToolListChangedOptions(options: ToolListChangedOptions | null): void { + // Set up tool list changed options and add notification handler + if (options) { + const parseResult = ToolListChangedOptionsSchema.safeParse(options); + if (parseResult.error) { + throw new Error(`Tool List Changed options are invalid: ${parseResult.error.message}`); + } + + const toolListChangedOptions = parseResult.data; + this._toolListChangedOptions = toolListChangedOptions; + + const refreshToolList = async () => { + // If autoRefresh is false, call the callback for the notification, but without tools data + if (!toolListChangedOptions.autoRefresh) { + toolListChangedOptions.onToolListChanged(null, null); + return; + } + + let tools: Tool[] | null = null; + let error: Error | null = null; + try { + const result = await this.listTools(); + tools = result.tools; + } catch (e) { + error = e instanceof Error ? e : new Error(String(e)); + } + toolListChangedOptions.onToolListChanged(error, tools); + }; + + this.setNotificationHandler(ToolListChangedNotificationSchema, () => { + if (toolListChangedOptions.debounceMs) { + // Clear any pending debounce timer + if (this._toolListChangedDebounceTimer) { + clearTimeout(this._toolListChangedDebounceTimer); + } + + // Set up debounced refresh + this._toolListChangedDebounceTimer = setTimeout(refreshToolList, toolListChangedOptions.debounceMs); + } else { + // No debounce, refresh immediately + refreshToolList(); + } + }); + } + // Reset tool list changed options and remove notification handler + else { + this._toolListChangedOptions = undefined; + this.removeNotificationHandler(ToolListChangedNotificationSchema.shape.method.value); + if (this._toolListChangedDebounceTimer) { + clearTimeout(this._toolListChangedDebounceTimer); + this._toolListChangedDebounceTimer = undefined; + } + } + } + + /** + * Gets the current tool list changed options + */ + public getToolListChangedOptions(): ToolListChangedOptions | undefined { + return this._toolListChangedOptions; + } + async sendRootsListChanged() { return this.notification({ method: 'notifications/roots/list_changed' }); } diff --git a/src/types.ts b/src/types.ts index 9e9ec3d31..d56132641 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1059,6 +1059,36 @@ export const ToolListChangedNotificationSchema = NotificationSchema.extend({ method: z.literal('notifications/tools/list_changed') }); +/** + * Client Options for tool list changed notifications. + */ +export const ToolListChangedOptionsSchema = z.object({ + /** + * If true, the tool list will be refreshed automatically when a tool list changed notification is received. + * + * If `onToolListChanged` is also provided, it will be called after the tool list is auto refreshed. + * + * @default true + */ + autoRefresh: z.boolean().default(true), + /** + * Debounce time in milliseconds for tool list changed notification processing. + * + * Multiple notifications received within this timeframe will only trigger one refresh. + * + * @default 300 + */ + debounceMs: z.number().int().default(300), + /** + * This callback is always called when the server sends a tool list changed notification. + * + * If `autoRefresh` is true, this callback will be called with updated tool list. + */ + onToolListChanged: z.function(z.tuple([z.instanceof(Error).nullable(), z.array(ToolSchema).nullable()]), z.void()) +}); + +export type ToolListChangedOptions = z.input; + /* Logging */ /** * The severity of a log message.