Skip to content

Commit e50874b

Browse files
committed
Add tool list changed handling to Client
1 parent 783d53b commit e50874b

File tree

3 files changed

+296
-2
lines changed

3 files changed

+296
-2
lines changed

src/client/index.test.ts

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import {
1616
CreateMessageRequestSchema,
1717
ElicitRequestSchema,
1818
ListRootsRequestSchema,
19-
ErrorCode
19+
ErrorCode,
20+
Tool
2021
} from '../types.js';
2122
import { Transport } from '../shared/transport.js';
2223
import { Server } from '../server/index.js';
@@ -773,6 +774,173 @@ test('should handle request timeout', async () => {
773774
});
774775
});
775776

777+
/***
778+
* Test: Handle Tool List Changed Notifications with Auto Refresh
779+
*/
780+
test('should handle tool list changed notification with auto refresh', async () => {
781+
// List changed notifications
782+
const notifications: [Error | null, Tool[] | null][] = [];
783+
784+
const server = new Server(
785+
{
786+
name: 'test-server',
787+
version: '1.0.0'
788+
},
789+
{
790+
capabilities: {
791+
tools: {
792+
listChanged: true
793+
}
794+
}
795+
}
796+
);
797+
798+
// Set up server handlers
799+
server.setRequestHandler(InitializeRequestSchema, async request => ({
800+
protocolVersion: request.params.protocolVersion,
801+
capabilities: {
802+
tools: {
803+
listChanged: true
804+
}
805+
},
806+
serverInfo: {
807+
name: 'test-server',
808+
version: '1.0.0'
809+
}
810+
}));
811+
812+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
813+
tools: []
814+
}));
815+
816+
const client = new Client({
817+
name: 'test-client',
818+
version: '1.0.0',
819+
}, {
820+
toolListChangedOptions: {
821+
autoRefresh: true,
822+
onToolListChanged: (err, tools) => {
823+
notifications.push([err, tools]);
824+
}
825+
}
826+
});
827+
828+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
829+
830+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
831+
832+
const result1 = await client.listTools();
833+
expect(result1.tools).toHaveLength(0);
834+
835+
// Update the tools list
836+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
837+
tools: [
838+
{
839+
name: 'test-tool',
840+
description: 'A test tool',
841+
inputSchema: {
842+
type: 'object',
843+
properties: {}
844+
}
845+
// No outputSchema
846+
}
847+
]
848+
}));
849+
await server.sendToolListChanged();
850+
851+
// Wait for the debounced notifications to be processed
852+
await new Promise(resolve => setTimeout(resolve, 1000));
853+
854+
// Should be 1 notification with 1 tool because autoRefresh is true
855+
expect(notifications).toHaveLength(1);
856+
expect(notifications[0][0]).toBeNull();
857+
expect(notifications[0][1]).toHaveLength(1);
858+
expect(notifications[0][1]?.[0].name).toBe('test-tool');
859+
});
860+
861+
/***
862+
* Test: Handle Tool List Changed Notifications with Manual Refresh
863+
*/
864+
test('should handle tool list changed notification with manual refresh', async () => {
865+
// List changed notifications
866+
const notifications: [Error | null, Tool[] | null][] = [];
867+
868+
const server = new Server(
869+
{
870+
name: 'test-server',
871+
version: '1.0.0'
872+
},
873+
{
874+
capabilities: {
875+
tools: {
876+
listChanged: true
877+
}
878+
}
879+
}
880+
);
881+
882+
// Set up server handlers
883+
server.setRequestHandler(InitializeRequestSchema, async request => ({
884+
protocolVersion: request.params.protocolVersion,
885+
capabilities: {
886+
tools: {
887+
listChanged: true
888+
}
889+
},
890+
serverInfo: {
891+
name: 'test-server',
892+
version: '1.0.0'
893+
}
894+
}));
895+
896+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
897+
tools: []
898+
}));
899+
900+
const client = new Client({
901+
name: 'test-client',
902+
version: '1.0.0',
903+
}, {
904+
toolListChangedOptions: {
905+
autoRefresh: false,
906+
onToolListChanged: (err, tools) => {
907+
notifications.push([err, tools]);
908+
}
909+
}
910+
});
911+
912+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
913+
914+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
915+
916+
const result1 = await client.listTools();
917+
expect(result1.tools).toHaveLength(0);
918+
919+
// Update the tools list
920+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
921+
tools: [
922+
{
923+
name: 'test-tool',
924+
description: 'A test tool',
925+
inputSchema: {
926+
type: 'object',
927+
properties: {}
928+
}
929+
// No outputSchema
930+
}
931+
]
932+
}));
933+
await server.sendToolListChanged();
934+
935+
// Wait for the debounced notifications to be processed
936+
await new Promise(resolve => setTimeout(resolve, 1000));
937+
938+
// Should be 1 notification with no tool data because autoRefresh is false
939+
expect(notifications).toHaveLength(1);
940+
expect(notifications[0][0]).toBeNull();
941+
expect(notifications[0][1]).toBeNull();
942+
});
943+
776944
describe('outputSchema validation', () => {
777945
/***
778946
* Test: Validate structuredContent Against outputSchema

src/client/index.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ import {
3636
SUPPORTED_PROTOCOL_VERSIONS,
3737
type SubscribeRequest,
3838
type Tool,
39-
type UnsubscribeRequest
39+
type UnsubscribeRequest,
40+
ToolListChangedNotificationSchema,
41+
ToolListChangedOptions
4042
} from '../types.js';
4143
import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js';
4244
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js';
@@ -77,6 +79,41 @@ export type ClientOptions = ProtocolOptions & {
7779
* ```
7880
*/
7981
jsonSchemaValidator?: jsonSchemaValidator;
82+
83+
/**
84+
* Configure automatic refresh behavior for tool list changed notifications
85+
*
86+
* @example
87+
* ```ts
88+
* {
89+
* autoRefresh: true,
90+
* debounceMs: 300,
91+
* onToolListChanged: (err, tools) => {
92+
* if (err) {
93+
* console.error('Failed to refresh tool list:', err);
94+
* return;
95+
* }
96+
* // Use the updated tool list
97+
* console.log('Tool list changed:', tools);
98+
* }
99+
* }
100+
* ```
101+
*
102+
* @example
103+
* ```ts
104+
* {
105+
* autoRefresh: false,
106+
* onToolListChanged: (err, tools) => {
107+
* // err is always null when autoRefresh is false
108+
*
109+
* // Manually refresh the tool list
110+
* const result = await this.listTools();
111+
* console.log('Tool list changed:', result.tools);
112+
* }
113+
* }
114+
* ```
115+
*/
116+
toolListChangedOptions?: ToolListChangedOptions;
80117
};
81118

82119
/**
@@ -115,6 +152,8 @@ export class Client<
115152
private _instructions?: string;
116153
private _jsonSchemaValidator: jsonSchemaValidator;
117154
private _cachedToolOutputValidators: Map<string, JsonSchemaValidator<unknown>> = new Map();
155+
private _toolListChangedOptions: ToolListChangedOptions | null = null;
156+
private _toolListChangedDebounceTimer?: ReturnType<typeof setTimeout>;
118157

119158
/**
120159
* Initializes this client with the given name and version information.
@@ -126,6 +165,9 @@ export class Client<
126165
super(options);
127166
this._capabilities = options?.capabilities ?? {};
128167
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator();
168+
169+
// Set up tool list changed options
170+
this.setToolListChangedOptions(options?.toolListChangedOptions || null);
129171
}
130172

131173
/**
@@ -434,6 +476,60 @@ export class Client<
434476
return result;
435477
}
436478

479+
/**
480+
* Updates the tool list changed options
481+
*
482+
* Set to null to disable tool list changed notifications
483+
*/
484+
public setToolListChangedOptions(options: ToolListChangedOptions | null): void {
485+
// Set up tool list changed options and add notification handler
486+
if (options) {
487+
const toolListChangedOptions: ToolListChangedOptions = {
488+
autoRefresh: !!options.autoRefresh,
489+
debounceMs: options.debounceMs ?? 300,
490+
onToolListChanged: options.onToolListChanged,
491+
};
492+
this._toolListChangedOptions = toolListChangedOptions;
493+
this.setNotificationHandler(ToolListChangedNotificationSchema, () => {
494+
// If autoRefresh is false, call the callback for the notification, but without tools data
495+
if (!toolListChangedOptions.autoRefresh) {
496+
toolListChangedOptions.onToolListChanged?.(null, null);
497+
return;
498+
}
499+
500+
// Clear any pending debounce timer
501+
if (this._toolListChangedDebounceTimer) {
502+
clearTimeout(this._toolListChangedDebounceTimer);
503+
}
504+
505+
// Set up debounced refresh
506+
this._toolListChangedDebounceTimer = setTimeout(async () => {
507+
let tools: Tool[] | null = null;
508+
let error: Error | null = null;
509+
try {
510+
const result = await this.listTools();
511+
tools = result.tools;
512+
} catch (e) {
513+
error = e instanceof Error ? e : new Error(String(e));
514+
}
515+
toolListChangedOptions.onToolListChanged?.(error, tools);
516+
}, toolListChangedOptions.debounceMs);
517+
});
518+
}
519+
// Reset tool list changed options and remove notification handler
520+
else {
521+
this._toolListChangedOptions = null;
522+
this.removeNotificationHandler(ToolListChangedNotificationSchema.shape.method.value);
523+
}
524+
}
525+
526+
/**
527+
* Gets the current tool list changed options
528+
*/
529+
public getToolListChangedOptions(): ToolListChangedOptions | null {
530+
return this._toolListChangedOptions;
531+
}
532+
437533
async sendRootsListChanged() {
438534
return this.notification({ method: 'notifications/roots/list_changed' });
439535
}

src/types.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,6 +1040,36 @@ export const ToolListChangedNotificationSchema = NotificationSchema.extend({
10401040
method: z.literal('notifications/tools/list_changed')
10411041
});
10421042

1043+
/**
1044+
* Client Options for tool list changed notifications.
1045+
*/
1046+
export const ToolListChangedOptionsSchema = z.object({
1047+
/**
1048+
* If true, the tool list will be refreshed automatically when a tool list changed notification is received.
1049+
*
1050+
* If `onToolListChanged` is also provided, it will be called after the tool list is auto refreshed.
1051+
*
1052+
* @default false
1053+
*/
1054+
autoRefresh: z.boolean().optional(),
1055+
/**
1056+
* Debounce time in milliseconds for tool list changed notification processing.
1057+
*
1058+
* Multiple notifications received within this timeframe will only trigger one refresh.
1059+
*
1060+
* @default 300
1061+
*/
1062+
debounceMs: z.number().int().optional(),
1063+
/**
1064+
* This callback is always called when the server sends a tool list changed notification.
1065+
*
1066+
* If `autoRefresh` is true, this callback will be called with updated tool list.
1067+
*/
1068+
onToolListChanged: z.function(z.tuple([z.instanceof(Error).nullable(), z.array(ToolSchema).nullable()]), z.void()),
1069+
});
1070+
1071+
export type ToolListChangedOptions = z.infer<typeof ToolListChangedOptionsSchema>;
1072+
10431073
/* Logging */
10441074
/**
10451075
* The severity of a log message.

0 commit comments

Comments
 (0)