From 68eee4d6b9cb7b85a34b1cfb70d0e172a11357de Mon Sep 17 00:00:00 2001 From: ChipGPT Date: Sat, 1 Nov 2025 14:27:47 -0500 Subject: [PATCH 1/8] Add tool list changed handling to Client --- src/client/index.test.ts | 168 +++++++++++++++++++++++++++++++++++++++ src/client/index.ts | 96 ++++++++++++++++++++++ src/types.ts | 30 +++++++ 3 files changed, 294 insertions(+) diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 4efd2adac..975c205ad 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -21,6 +21,7 @@ import { ErrorCode, McpError, CreateTaskResultSchema + Tool } from '../types.js'; import { Transport } from '../shared/transport.js'; import { Server } from '../server/index.js'; @@ -1229,6 +1230,173 @@ 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: { + autoRefresh: true, + 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, + 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 0fb6cdcf3..dcdd281bd 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -43,6 +43,8 @@ import { CreateTaskResultSchema, CreateMessageRequestSchema, CreateMessageResultSchema + ToolListChangedNotificationSchema, + ToolListChangedOptions } from '../types.js'; import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js'; @@ -163,6 +165,41 @@ export type ClientOptions = ProtocolOptions & { * ``` */ jsonSchemaValidator?: jsonSchemaValidator; + + /** + * Configure automatic refresh behavior for tool list changed notifications + * + * @example + * ```ts + * { + * autoRefresh: true, + * debounceMs: 300, + * 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); + * } + * } + * ``` + * + * @example + * ```ts + * { + * autoRefresh: false, + * 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; }; /** @@ -204,6 +241,8 @@ export class Client< private _cachedKnownTaskTools: Set = new Set(); private _cachedRequiredTaskTools: Set = new Set(); private _experimental?: { tasks: ExperimentalClientTasks }; + private _toolListChangedOptions: ToolListChangedOptions | null = null; + private _toolListChangedDebounceTimer?: ReturnType; /** * Initializes this client with the given name and version information. @@ -215,6 +254,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); } /** @@ -757,6 +799,60 @@ 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 toolListChangedOptions: ToolListChangedOptions = { + autoRefresh: !!options.autoRefresh, + debounceMs: options.debounceMs ?? 300, + onToolListChanged: options.onToolListChanged, + }; + this._toolListChangedOptions = toolListChangedOptions; + this.setNotificationHandler(ToolListChangedNotificationSchema, () => { + // If autoRefresh is false, call the callback for the notification, but without tools data + if (!toolListChangedOptions.autoRefresh) { + toolListChangedOptions.onToolListChanged?.(null, null); + return; + } + + // Clear any pending debounce timer + if (this._toolListChangedDebounceTimer) { + clearTimeout(this._toolListChangedDebounceTimer); + } + + // Set up debounced refresh + this._toolListChangedDebounceTimer = setTimeout(async () => { + 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); + }, toolListChangedOptions.debounceMs); + }); + } + // Reset tool list changed options and remove notification handler + else { + this._toolListChangedOptions = null; + this.removeNotificationHandler(ToolListChangedNotificationSchema.shape.method.value); + } + } + + /** + * Gets the current tool list changed options + */ + public getToolListChangedOptions(): ToolListChangedOptions | null { + return this._toolListChangedOptions; + } + async sendRootsListChanged() { return this.notification({ method: 'notifications/roots/list_changed' }); } diff --git a/src/types.ts b/src/types.ts index 03acc3e6a..104738c59 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1381,6 +1381,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 false + */ + autoRefresh: z.boolean().optional(), + /** + * 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().optional(), + /** + * 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.infer; + /* Logging */ /** * The severity of a log message. From 67988c2d67288644777fbfc65e7571be9c79adcd Mon Sep 17 00:00:00 2001 From: ChipGPT Date: Sat, 1 Nov 2025 14:53:29 -0500 Subject: [PATCH 2/8] delint --- src/client/index.test.ts | 42 +++++++++++++++++++++++----------------- src/client/index.ts | 10 +++++----- src/types.ts | 2 +- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 975c205ad..743a72003 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -1269,17 +1269,20 @@ test('should handle tool list changed notification with auto refresh', async () tools: [] })); - const client = new Client({ - name: 'test-client', - version: '1.0.0', - }, { - toolListChangedOptions: { - autoRefresh: true, - onToolListChanged: (err, tools) => { - notifications.push([err, tools]); + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + toolListChangedOptions: { + autoRefresh: true, + onToolListChanged: (err, tools) => { + notifications.push([err, tools]); + } } } - }); + ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -1353,17 +1356,20 @@ test('should handle tool list changed notification with manual refresh', async ( tools: [] })); - const client = new Client({ - name: 'test-client', - version: '1.0.0', - }, { - toolListChangedOptions: { - autoRefresh: false, - onToolListChanged: (err, tools) => { - notifications.push([err, tools]); + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + toolListChangedOptions: { + autoRefresh: false, + onToolListChanged: (err, tools) => { + notifications.push([err, tools]); + } } } - }); + ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); diff --git a/src/client/index.ts b/src/client/index.ts index dcdd281bd..ff08e2a50 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -169,7 +169,7 @@ export type ClientOptions = ProtocolOptions & { /** * Configure automatic refresh behavior for tool list changed notifications * - * @example + * @example * ```ts * { * autoRefresh: true, @@ -191,7 +191,7 @@ export type ClientOptions = ProtocolOptions & { * autoRefresh: false, * 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); @@ -810,7 +810,7 @@ export class Client< const toolListChangedOptions: ToolListChangedOptions = { autoRefresh: !!options.autoRefresh, debounceMs: options.debounceMs ?? 300, - onToolListChanged: options.onToolListChanged, + onToolListChanged: options.onToolListChanged }; this._toolListChangedOptions = toolListChangedOptions; this.setNotificationHandler(ToolListChangedNotificationSchema, () => { @@ -819,12 +819,12 @@ export class Client< toolListChangedOptions.onToolListChanged?.(null, null); return; } - + // Clear any pending debounce timer if (this._toolListChangedDebounceTimer) { clearTimeout(this._toolListChangedDebounceTimer); } - + // Set up debounced refresh this._toolListChangedDebounceTimer = setTimeout(async () => { let tools: Tool[] | null = null; diff --git a/src/types.ts b/src/types.ts index 104738c59..e0469468b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1406,7 +1406,7 @@ export const ToolListChangedOptionsSchema = z.object({ * * 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()), + onToolListChanged: z.function(z.tuple([z.instanceof(Error).nullable(), z.array(ToolSchema).nullable()]), z.void()) }); export type ToolListChangedOptions = z.infer; From 3613bc05980764de3dbd154c4df6394939459592 Mon Sep 17 00:00:00 2001 From: ChipGPT Date: Sat, 1 Nov 2025 18:03:02 -0500 Subject: [PATCH 3/8] use z.input type --- src/client/index.ts | 65 +++++++++++++++++++++++++++------------------ src/types.ts | 8 +++--- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index ff08e2a50..cd141ea0f 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -44,7 +44,8 @@ import { CreateMessageRequestSchema, CreateMessageResultSchema ToolListChangedNotificationSchema, - ToolListChangedOptions + ToolListChangedOptions, + ToolListChangedOptionsSchema } from '../types.js'; import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js'; @@ -169,11 +170,11 @@ export type ClientOptions = ProtocolOptions & { /** * 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 - * ```ts + * ```typescript * { - * autoRefresh: true, - * debounceMs: 300, * onToolListChanged: (err, tools) => { * if (err) { * console.error('Failed to refresh tool list:', err); @@ -185,10 +186,13 @@ export type ClientOptions = ProtocolOptions & { * } * ``` * + * Here is an example of how to manually refresh the tool list when the tool list changed notification is received: + * * @example - * ```ts + * ```typescript * { * autoRefresh: false, + * debounceMs: 0, * onToolListChanged: (err, tools) => { * // err is always null when autoRefresh is false * @@ -807,12 +811,26 @@ export class Client< public setToolListChangedOptions(options: ToolListChangedOptions | null): void { // Set up tool list changed options and add notification handler if (options) { - const toolListChangedOptions: ToolListChangedOptions = { - autoRefresh: !!options.autoRefresh, - debounceMs: options.debounceMs ?? 300, - onToolListChanged: options.onToolListChanged - }; + 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 () => { + 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 autoRefresh is false, call the callback for the notification, but without tools data if (!toolListChangedOptions.autoRefresh) { @@ -820,23 +838,18 @@ export class Client< return; } - // Clear any pending debounce timer - if (this._toolListChangedDebounceTimer) { - clearTimeout(this._toolListChangedDebounceTimer); - } - - // Set up debounced refresh - this._toolListChangedDebounceTimer = setTimeout(async () => { - 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)); + if (toolListChangedOptions.debounceMs) { + // Clear any pending debounce timer + if (this._toolListChangedDebounceTimer) { + clearTimeout(this._toolListChangedDebounceTimer); } - toolListChangedOptions.onToolListChanged?.(error, tools); - }, toolListChangedOptions.debounceMs); + + // 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 diff --git a/src/types.ts b/src/types.ts index e0469468b..5c91a8dff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1390,9 +1390,9 @@ export const ToolListChangedOptionsSchema = z.object({ * * If `onToolListChanged` is also provided, it will be called after the tool list is auto refreshed. * - * @default false + * @default true */ - autoRefresh: z.boolean().optional(), + autoRefresh: z.boolean().default(true), /** * Debounce time in milliseconds for tool list changed notification processing. * @@ -1400,7 +1400,7 @@ export const ToolListChangedOptionsSchema = z.object({ * * @default 300 */ - debounceMs: z.number().int().optional(), + debounceMs: z.number().int().default(300), /** * This callback is always called when the server sends a tool list changed notification. * @@ -1409,7 +1409,7 @@ export const ToolListChangedOptionsSchema = z.object({ onToolListChanged: z.function(z.tuple([z.instanceof(Error).nullable(), z.array(ToolSchema).nullable()]), z.void()) }); -export type ToolListChangedOptions = z.infer; +export type ToolListChangedOptions = z.input; /* Logging */ /** From 3c55425fabd92e39578a76998ff3d88bf103fd52 Mon Sep 17 00:00:00 2001 From: ChipGPT Date: Sat, 1 Nov 2025 18:10:04 -0500 Subject: [PATCH 4/8] update tests --- src/client/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 743a72003..738ced43a 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -1276,7 +1276,6 @@ test('should handle tool list changed notification with auto refresh', async () }, { toolListChangedOptions: { - autoRefresh: true, onToolListChanged: (err, tools) => { notifications.push([err, tools]); } @@ -1364,6 +1363,7 @@ test('should handle tool list changed notification with manual refresh', async ( { toolListChangedOptions: { autoRefresh: false, + debounceMs: 0, onToolListChanged: (err, tools) => { notifications.push([err, tools]); } From 94488b64a758d43ebcc03f3353bcbe50d6545093 Mon Sep 17 00:00:00 2001 From: ChipGPT Date: Sat, 1 Nov 2025 18:25:00 -0500 Subject: [PATCH 5/8] debounce the handler calls --- src/client/index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index cd141ea0f..b7c8a69d2 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -820,6 +820,12 @@ export class Client< 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 { @@ -832,12 +838,6 @@ export class Client< }; this.setNotificationHandler(ToolListChangedNotificationSchema, () => { - // If autoRefresh is false, call the callback for the notification, but without tools data - if (!toolListChangedOptions.autoRefresh) { - toolListChangedOptions.onToolListChanged?.(null, null); - return; - } - if (toolListChangedOptions.debounceMs) { // Clear any pending debounce timer if (this._toolListChangedDebounceTimer) { From 31bc8dfbbb80ce0d1e9a0598b86f456787411b2f Mon Sep 17 00:00:00 2001 From: ChipGPT Date: Tue, 4 Nov 2025 10:37:34 -0600 Subject: [PATCH 6/8] clear debounce on unsubscribe --- src/client/index.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index b7c8a69d2..6753fb3db 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -245,7 +245,7 @@ export class Client< private _cachedKnownTaskTools: Set = new Set(); private _cachedRequiredTaskTools: Set = new Set(); private _experimental?: { tasks: ExperimentalClientTasks }; - private _toolListChangedOptions: ToolListChangedOptions | null = null; + private _toolListChangedOptions?: ToolListChangedOptions; private _toolListChangedDebounceTimer?: ReturnType; /** @@ -822,7 +822,7 @@ export class Client< const refreshToolList = async () => { // If autoRefresh is false, call the callback for the notification, but without tools data if (!toolListChangedOptions.autoRefresh) { - toolListChangedOptions.onToolListChanged?.(null, null); + toolListChangedOptions.onToolListChanged(null, null); return; } @@ -834,7 +834,7 @@ export class Client< } catch (e) { error = e instanceof Error ? e : new Error(String(e)); } - toolListChangedOptions.onToolListChanged?.(error, tools); + toolListChangedOptions.onToolListChanged(error, tools); }; this.setNotificationHandler(ToolListChangedNotificationSchema, () => { @@ -854,15 +854,19 @@ export class Client< } // Reset tool list changed options and remove notification handler else { - this._toolListChangedOptions = null; + 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 | null { + public getToolListChangedOptions(): ToolListChangedOptions | undefined { return this._toolListChangedOptions; } From ee738246201292b5fef24da0b634d9caf87c8ecc Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Mon, 1 Dec 2025 17:52:43 +0000 Subject: [PATCH 7/8] fix: formatting --- src/client/index.test.ts | 2 +- src/client/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 738ced43a..e4a8b0f7f 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -20,7 +20,7 @@ import { ListRootsRequestSchema, ErrorCode, McpError, - CreateTaskResultSchema + CreateTaskResultSchema, Tool } from '../types.js'; import { Transport } from '../shared/transport.js'; diff --git a/src/client/index.ts b/src/client/index.ts index 6753fb3db..2faa5cc86 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -42,7 +42,7 @@ import { ElicitRequestSchema, CreateTaskResultSchema, CreateMessageRequestSchema, - CreateMessageResultSchema + CreateMessageResultSchema, ToolListChangedNotificationSchema, ToolListChangedOptions, ToolListChangedOptionsSchema From 161d993f973eaf44881a84348ddb1edb770d01d3 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Mon, 1 Dec 2025 18:03:14 +0000 Subject: [PATCH 8/8] fix: use z.custom instead of z.function --- src/client/index.ts | 19 ++++++++++--------- src/types.ts | 11 +++++++++-- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 2faa5cc86..edf941487 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -811,18 +811,19 @@ export class Client< public setToolListChangedOptions(options: ToolListChangedOptions | null): void { // Set up tool list changed options and add notification handler if (options) { + // Validate and apply defaults using Zod schema const parseResult = ToolListChangedOptionsSchema.safeParse(options); - if (parseResult.error) { - throw new Error(`Tool List Changed options are invalid: ${parseResult.error.message}`); + if (!parseResult.success) { + throw new Error(`Invalid toolListChangedOptions: ${parseResult.error.message}`); } - const toolListChangedOptions = parseResult.data; - this._toolListChangedOptions = toolListChangedOptions; + const { autoRefresh, debounceMs, onToolListChanged } = parseResult.data; + this._toolListChangedOptions = options; const refreshToolList = async () => { // If autoRefresh is false, call the callback for the notification, but without tools data - if (!toolListChangedOptions.autoRefresh) { - toolListChangedOptions.onToolListChanged(null, null); + if (!autoRefresh) { + onToolListChanged(null, null); return; } @@ -834,18 +835,18 @@ export class Client< } catch (e) { error = e instanceof Error ? e : new Error(String(e)); } - toolListChangedOptions.onToolListChanged(error, tools); + onToolListChanged(error, tools); }; this.setNotificationHandler(ToolListChangedNotificationSchema, () => { - if (toolListChangedOptions.debounceMs) { + if (debounceMs) { // Clear any pending debounce timer if (this._toolListChangedDebounceTimer) { clearTimeout(this._toolListChangedDebounceTimer); } // Set up debounced refresh - this._toolListChangedDebounceTimer = setTimeout(refreshToolList, toolListChangedOptions.debounceMs); + this._toolListChangedDebounceTimer = setTimeout(refreshToolList, debounceMs); } else { // No debounce, refresh immediately refreshToolList(); diff --git a/src/types.ts b/src/types.ts index 5c91a8dff..66904a7a3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1381,6 +1381,11 @@ export const ToolListChangedNotificationSchema = NotificationSchema.extend({ method: z.literal('notifications/tools/list_changed') }); +/** + * Callback type for tool list changed notifications. + */ +export type ToolListChangedCallback = (error: Error | null, tools: Tool[] | null) => void; + /** * Client Options for tool list changed notifications. */ @@ -1400,13 +1405,15 @@ export const ToolListChangedOptionsSchema = z.object({ * * @default 300 */ - debounceMs: z.number().int().default(300), + debounceMs: z.number().int().nonnegative().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()) + onToolListChanged: z.custom((val): val is ToolListChangedCallback => typeof val === 'function', { + message: 'onToolListChanged must be a function' + }) }); export type ToolListChangedOptions = z.input;