From 68eee4d6b9cb7b85a34b1cfb70d0e172a11357de Mon Sep 17 00:00:00 2001 From: ChipGPT Date: Sat, 1 Nov 2025 14:27:47 -0500 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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; From 2ea50683907d137ddd9f80e338c8fd7434b9f74d Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Mon, 1 Dec 2025 18:36:06 +0000 Subject: [PATCH 09/10] feat: have list changed for all prompts, tools and resources --- src/client/index.test.ts | 280 +++++++++++++++++++++++++++++++++++++-- src/client/index.ts | 197 ++++++++++++++------------- src/types.ts | 69 +++++++--- 3 files changed, 428 insertions(+), 118 deletions(-) diff --git a/src/client/index.test.ts b/src/client/index.test.ts index e4a8b0f7f..1b2e02c60 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, @@ -21,7 +22,9 @@ import { ErrorCode, McpError, CreateTaskResultSchema, - Tool + Tool, + Prompt, + Resource } from '../types.js'; import { Transport } from '../shared/transport.js'; import { Server } from '../server/index.js'; @@ -1275,9 +1278,11 @@ test('should handle tool list changed notification with auto refresh', async () version: '1.0.0' }, { - toolListChangedOptions: { - onToolListChanged: (err, tools) => { - notifications.push([err, tools]); + listChanged: { + tools: { + onChanged: (err, tools) => { + notifications.push([err, tools]); + } } } } @@ -1361,11 +1366,13 @@ test('should handle tool list changed notification with manual refresh', async ( version: '1.0.0' }, { - toolListChangedOptions: { - autoRefresh: false, - debounceMs: 0, - onToolListChanged: (err, tools) => { - notifications.push([err, tools]); + listChanged: { + tools: { + autoRefresh: false, + debounceMs: 0, + onChanged: (err, tools) => { + notifications.push([err, tools]); + } } } } @@ -1394,8 +1401,8 @@ test('should handle tool list changed notification with manual refresh', async ( })); await server.sendToolListChanged(); - // Wait for the debounced notifications to be processed - await new Promise(resolve => setTimeout(resolve, 1000)); + // 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); @@ -1403,6 +1410,257 @@ test('should handle tool list changed notification with manual refresh', async ( 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 Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + prompts: { + listChanged: true + } + } + } + ); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: { + prompts: { + listChanged: true + } + }, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler(ListPromptsRequestSchema, async () => ({ + prompts: [] + })); + + // 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(0); + + // Update the prompts list + server.setRequestHandler(ListPromptsRequestSchema, async () => ({ + prompts: [ + { + name: 'test-prompt', + description: 'A test prompt' + } + ] + })); + await server.sendPromptListChanged(); + + // Wait for the debounced notifications to be processed + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Should be 1 notification with 1 prompt 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-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 Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + resources: { + listChanged: true + } + } + } + ); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: { + resources: { + listChanged: true + } + }, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler(ListResourcesRequestSchema, async () => ({ + resources: [] + })); + + // 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(0); + + // Update the resources list + server.setRequestHandler(ListResourcesRequestSchema, async () => ({ + resources: [ + { + uri: 'file:///test.txt', + name: 'test-resource', + description: 'A test resource' + } + ] + })); + await server.sendResourceListChanged(); + + // Wait for the debounced notifications to be processed + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Should be 1 notification with 1 resource 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-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 Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: { listChanged: true }, + prompts: { listChanged: true } + } + } + ); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: { + tools: { listChanged: true }, + prompts: { listChanged: true } + }, + 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' }] + })); + + // 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)]); + + // Send both notifications + await server.sendToolListChanged(); + await server.sendPromptListChanged(); + + // 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]?.[0].name).toBe('tool-1'); + + expect(promptNotifications).toHaveLength(1); + expect(promptNotifications[0][1]?.[0].name).toBe('prompt-1'); +}); + describe('outputSchema validation', () => { /*** * Test: Validate structuredContent Against outputSchema diff --git a/src/client/index.ts b/src/client/index.ts index edf941487..2451d3d24 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -44,8 +44,11 @@ import { CreateMessageRequestSchema, CreateMessageResultSchema, ToolListChangedNotificationSchema, - ToolListChangedOptions, - ToolListChangedOptionsSchema + 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'; @@ -168,42 +171,32 @@ 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: + * Configure handlers for list changed notifications (tools, prompts, resources). * * @example * ```typescript - * { - * onToolListChanged: (err, tools) => { - * if (err) { - * console.error('Failed to refresh tool list:', err); - * return; + * 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) + * } * } - * // 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; + listChanged?: ListChangedHandlers; }; /** @@ -245,8 +238,7 @@ export class Client< private _cachedKnownTaskTools: Set = new Set(); private _cachedRequiredTaskTools: Set = new Set(); private _experimental?: { tasks: ExperimentalClientTasks }; - private _toolListChangedOptions?: ToolListChangedOptions; - private _toolListChangedDebounceTimer?: ReturnType; + private _listChangedDebounceTimers: Map> = new Map(); /** * Initializes this client with the given name and version information. @@ -259,8 +251,37 @@ export class Client< this._capabilities = options?.capabilities ?? {}; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator(); - // Set up tool list changed options - this.setToolListChangedOptions(options?.toolListChangedOptions || null); + // 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; + }); + } } /** @@ -804,71 +825,63 @@ export class Client< } /** - * Updates the tool list changed options - * - * Set to null to disable tool list changed notifications + * Set up a single list changed handler. + * @internal */ - 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.success) { - throw new Error(`Invalid toolListChangedOptions: ${parseResult.error.message}`); - } - - const { autoRefresh, debounceMs, onToolListChanged } = parseResult.data; - this._toolListChangedOptions = options; + 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}`); + } - const refreshToolList = async () => { - // If autoRefresh is false, call the callback for the notification, but without tools data - if (!autoRefresh) { - onToolListChanged(null, null); - return; - } + // Validate callback + if (typeof options.onChanged !== 'function') { + throw new Error(`Invalid ${listType} listChanged options: onChanged must be a function`); + } - 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)); - } - onToolListChanged(error, tools); - }; + const { autoRefresh, debounceMs } = parseResult.data; + const { onChanged } = options; - this.setNotificationHandler(ToolListChangedNotificationSchema, () => { - if (debounceMs) { - // Clear any pending debounce timer - if (this._toolListChangedDebounceTimer) { - clearTimeout(this._toolListChangedDebounceTimer); - } + const refresh = async () => { + if (!autoRefresh) { + onChanged(null, null); + return; + } - // Set up debounced refresh - this._toolListChangedDebounceTimer = setTimeout(refreshToolList, debounceMs); - } else { - // No debounce, refresh immediately - refreshToolList(); + 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); } - }); - } - // 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; + + // Set up debounced refresh + const timer = setTimeout(refresh, debounceMs); + this._listChangedDebounceTimers.set(listType, timer); + } else { + // No debounce, refresh immediately + refresh(); } - } - } + }; - /** - * Gets the current tool list changed options - */ - public getToolListChangedOptions(): ToolListChangedOptions | undefined { - return this._toolListChangedOptions; + // Register notification handler + this.setNotificationHandler(notificationSchema as AnyObjectSchema, handler); } async sendRootsListChanged() { diff --git a/src/types.ts b/src/types.ts index 66904a7a3..e0838b5e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1382,41 +1382,80 @@ export const ToolListChangedNotificationSchema = NotificationSchema.extend({ }); /** - * Callback type for tool list changed notifications. + * Callback type for list changed notifications. */ -export type ToolListChangedCallback = (error: Error | null, tools: Tool[] | null) => void; +export type ListChangedCallback = (error: Error | null, items: T[] | null) => void; /** - * Client Options for tool list changed notifications. + * Base schema for list changed subscription options (without callback). + * Used internally for Zod validation of autoRefresh and debounceMs. */ -export const ToolListChangedOptionsSchema = z.object({ +export const ListChangedOptionsBaseSchema = z.object({ /** - * If true, the tool list will be refreshed automatically when a tool list changed notification is received. + * 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 `onToolListChanged` is also provided, it will be called after the tool list is auto refreshed. + * 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 tool list changed notification processing. + * 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), + 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; /** - * This callback is always called when the server sends a tool list changed notification. + * Callback invoked when the list changes. * - * If `autoRefresh` is true, this callback will be called with updated tool list. + * If autoRefresh is true, items contains the updated list. + * If autoRefresh is false, items is null (caller should refresh manually). */ - onToolListChanged: z.custom((val): val is ToolListChangedCallback => typeof val === 'function', { - message: 'onToolListChanged must be a function' - }) -}); + onChanged: ListChangedCallback; +}; -export type ToolListChangedOptions = z.input; +/** + * 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 */ /** From 5620e32519427e97311c13a703c36a7d0bb1ce7e Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Mon, 1 Dec 2025 18:36:40 +0000 Subject: [PATCH 10/10] use McpServer not Server --- src/client/index.test.ts | 321 +++++++++++++-------------------------- 1 file changed, 109 insertions(+), 212 deletions(-) diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 1b2e02c60..9ef3d1c4a 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -12,7 +12,6 @@ import { ListResourcesRequestSchema, ListToolsRequestSchema, ListToolsResultSchema, - ListPromptsRequestSchema, CallToolRequestSchema, CallToolResultSchema, CreateMessageRequestSchema, @@ -1240,38 +1239,21 @@ test('should handle tool list changed notification with auto refresh', async () // List changed notifications const notifications: [Error | null, Tool[] | null][] = []; - const server = new Server( + const server = new McpServer({ + name: 'test-server', + version: '1.0.0' + }); + + // Register initial tool to enable the tools capability + server.registerTool( + 'initial-tool', { - name: 'test-server', - version: '1.0.0' + description: 'Initial tool' }, - { - capabilities: { - tools: { - listChanged: true - } - } - } + async () => ({ content: [] }) ); - // 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: [] - })); - + // Configure listChanged handler in constructor const client = new Client( { name: 'test-client', @@ -1293,32 +1275,25 @@ test('should handle tool list changed notification with auto refresh', async () await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); const result1 = await client.listTools(); - expect(result1.tools).toHaveLength(0); + expect(result1.tools).toHaveLength(1); - // 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(); + // 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 1 tool because autoRefresh is true + // 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(1); - expect(notifications[0][1]?.[0].name).toBe('test-tool'); + expect(notifications[0][1]).toHaveLength(2); + expect(notifications[0][1]?.[1].name).toBe('test-tool'); }); /*** @@ -1328,38 +1303,15 @@ 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' - } - })); + const server = new McpServer({ + name: 'test-server', + version: '1.0.0' + }); - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [] - })); + // 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', @@ -1383,23 +1335,16 @@ test('should handle tool list changed notification with manual refresh', async ( await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); const result1 = await client.listTools(); - expect(result1.tools).toHaveLength(0); + expect(result1.tools).toHaveLength(1); - // 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(); + // 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)); @@ -1416,38 +1361,22 @@ test('should handle tool list changed notification with manual refresh', async ( test('should handle prompt list changed notification with auto refresh', async () => { const notifications: [Error | null, Prompt[] | null][] = []; - const server = new Server( + const server = new McpServer({ + name: 'test-server', + version: '1.0.0' + }); + + // Register initial prompt to enable the prompts capability + server.registerPrompt( + 'initial-prompt', { - name: 'test-server', - version: '1.0.0' + description: 'Initial prompt' }, - { - capabilities: { - prompts: { - listChanged: true - } - } - } + async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }] + }) ); - // Set up server handlers - server.setRequestHandler(InitializeRequestSchema, async request => ({ - protocolVersion: request.params.protocolVersion, - capabilities: { - prompts: { - listChanged: true - } - }, - serverInfo: { - name: 'test-server', - version: '1.0.0' - } - })); - - server.setRequestHandler(ListPromptsRequestSchema, async () => ({ - prompts: [] - })); - // Configure listChanged handler in constructor const client = new Client( { @@ -1470,27 +1399,21 @@ test('should handle prompt list changed notification with auto refresh', async ( await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); const result1 = await client.listPrompts(); - expect(result1.prompts).toHaveLength(0); + expect(result1.prompts).toHaveLength(1); - // Update the prompts list - server.setRequestHandler(ListPromptsRequestSchema, async () => ({ - prompts: [ - { - name: 'test-prompt', - description: 'A test prompt' - } - ] + // Register another prompt - this triggers listChanged notification + server.registerPrompt('test-prompt', { description: 'A test prompt' }, async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }] })); - await server.sendPromptListChanged(); // Wait for the debounced notifications to be processed await new Promise(resolve => setTimeout(resolve, 1000)); - // Should be 1 notification with 1 prompt because autoRefresh is true + // 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(1); - expect(notifications[0][1]?.[0].name).toBe('test-prompt'); + expect(notifications[0][1]).toHaveLength(2); + expect(notifications[0][1]?.[1].name).toBe('test-prompt'); }); /*** @@ -1499,36 +1422,14 @@ test('should handle prompt list changed notification with auto refresh', async ( test('should handle resource list changed notification with auto refresh', async () => { const notifications: [Error | null, Resource[] | null][] = []; - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - resources: { - listChanged: true - } - } - } - ); - - // Set up server handlers - server.setRequestHandler(InitializeRequestSchema, async request => ({ - protocolVersion: request.params.protocolVersion, - capabilities: { - resources: { - listChanged: true - } - }, - serverInfo: { - name: 'test-server', - version: '1.0.0' - } - })); + const server = new McpServer({ + name: 'test-server', + version: '1.0.0' + }); - server.setRequestHandler(ListResourcesRequestSchema, async () => ({ - resources: [] + // 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 @@ -1553,28 +1454,21 @@ test('should handle resource list changed notification with auto refresh', async await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); const result1 = await client.listResources(); - expect(result1.resources).toHaveLength(0); + expect(result1.resources).toHaveLength(1); - // Update the resources list - server.setRequestHandler(ListResourcesRequestSchema, async () => ({ - resources: [ - { - uri: 'file:///test.txt', - name: 'test-resource', - description: 'A test resource' - } - ] + // Register another resource - this triggers listChanged notification + server.registerResource('test-resource', 'file:///test.txt', {}, async () => ({ + contents: [{ uri: 'file:///test.txt', text: 'Hello' }] })); - await server.sendResourceListChanged(); // Wait for the debounced notifications to be processed await new Promise(resolve => setTimeout(resolve, 1000)); - // Should be 1 notification with 1 resource because autoRefresh is true + // 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(1); - expect(notifications[0][1]?.[0].name).toBe('test-resource'); + expect(notifications[0][1]).toHaveLength(2); + expect(notifications[0][1]?.[1].name).toBe('test-resource'); }); /*** @@ -1584,39 +1478,28 @@ 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 Server( + const server = new McpServer({ + name: 'test-server', + version: '1.0.0' + }); + + // Register initial tool and prompt to enable capabilities + server.registerTool( + 'tool-1', { - name: 'test-server', - version: '1.0.0' + description: 'Tool 1' }, - { - capabilities: { - tools: { listChanged: true }, - prompts: { listChanged: true } - } - } + async () => ({ content: [] }) ); - - // Set up server handlers - server.setRequestHandler(InitializeRequestSchema, async request => ({ - protocolVersion: request.params.protocolVersion, - capabilities: { - tools: { listChanged: true }, - prompts: { listChanged: true } + server.registerPrompt( + 'prompt-1', + { + description: 'Prompt 1' }, - 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' }] - })); + async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }] + }) + ); // Configure multiple listChanged handlers in constructor const client = new Client( @@ -1646,19 +1529,33 @@ test('should handle multiple list changed handlers configured together', async ( await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - // Send both notifications - await server.sendToolListChanged(); - await server.sendPromptListChanged(); + // 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]?.[0].name).toBe('tool-1'); + expect(toolNotifications[0][1]).toHaveLength(2); expect(promptNotifications).toHaveLength(1); - expect(promptNotifications[0][1]?.[0].name).toBe('prompt-1'); + expect(promptNotifications[0][1]).toHaveLength(2); }); describe('outputSchema validation', () => {