Skip to content

Commit 68eee4d

Browse files
chipgptmattzcarey
authored andcommitted
Add tool list changed handling to Client
1 parent a6ee2cb commit 68eee4d

File tree

3 files changed

+294
-0
lines changed

3 files changed

+294
-0
lines changed

src/client/index.test.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
ErrorCode,
2222
McpError,
2323
CreateTaskResultSchema
24+
Tool
2425
} from '../types.js';
2526
import { Transport } from '../shared/transport.js';
2627
import { Server } from '../server/index.js';
@@ -1229,6 +1230,173 @@ test('should handle request timeout', async () => {
12291230
});
12301231
});
12311232

1233+
/***
1234+
* Test: Handle Tool List Changed Notifications with Auto Refresh
1235+
*/
1236+
test('should handle tool list changed notification with auto refresh', async () => {
1237+
// List changed notifications
1238+
const notifications: [Error | null, Tool[] | null][] = [];
1239+
1240+
const server = new Server(
1241+
{
1242+
name: 'test-server',
1243+
version: '1.0.0'
1244+
},
1245+
{
1246+
capabilities: {
1247+
tools: {
1248+
listChanged: true
1249+
}
1250+
}
1251+
}
1252+
);
1253+
1254+
// Set up server handlers
1255+
server.setRequestHandler(InitializeRequestSchema, async request => ({
1256+
protocolVersion: request.params.protocolVersion,
1257+
capabilities: {
1258+
tools: {
1259+
listChanged: true
1260+
}
1261+
},
1262+
serverInfo: {
1263+
name: 'test-server',
1264+
version: '1.0.0'
1265+
}
1266+
}));
1267+
1268+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
1269+
tools: []
1270+
}));
1271+
1272+
const client = new Client({
1273+
name: 'test-client',
1274+
version: '1.0.0',
1275+
}, {
1276+
toolListChangedOptions: {
1277+
autoRefresh: true,
1278+
onToolListChanged: (err, tools) => {
1279+
notifications.push([err, tools]);
1280+
}
1281+
}
1282+
});
1283+
1284+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
1285+
1286+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
1287+
1288+
const result1 = await client.listTools();
1289+
expect(result1.tools).toHaveLength(0);
1290+
1291+
// Update the tools list
1292+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
1293+
tools: [
1294+
{
1295+
name: 'test-tool',
1296+
description: 'A test tool',
1297+
inputSchema: {
1298+
type: 'object',
1299+
properties: {}
1300+
}
1301+
// No outputSchema
1302+
}
1303+
]
1304+
}));
1305+
await server.sendToolListChanged();
1306+
1307+
// Wait for the debounced notifications to be processed
1308+
await new Promise(resolve => setTimeout(resolve, 1000));
1309+
1310+
// Should be 1 notification with 1 tool because autoRefresh is true
1311+
expect(notifications).toHaveLength(1);
1312+
expect(notifications[0][0]).toBeNull();
1313+
expect(notifications[0][1]).toHaveLength(1);
1314+
expect(notifications[0][1]?.[0].name).toBe('test-tool');
1315+
});
1316+
1317+
/***
1318+
* Test: Handle Tool List Changed Notifications with Manual Refresh
1319+
*/
1320+
test('should handle tool list changed notification with manual refresh', async () => {
1321+
// List changed notifications
1322+
const notifications: [Error | null, Tool[] | null][] = [];
1323+
1324+
const server = new Server(
1325+
{
1326+
name: 'test-server',
1327+
version: '1.0.0'
1328+
},
1329+
{
1330+
capabilities: {
1331+
tools: {
1332+
listChanged: true
1333+
}
1334+
}
1335+
}
1336+
);
1337+
1338+
// Set up server handlers
1339+
server.setRequestHandler(InitializeRequestSchema, async request => ({
1340+
protocolVersion: request.params.protocolVersion,
1341+
capabilities: {
1342+
tools: {
1343+
listChanged: true
1344+
}
1345+
},
1346+
serverInfo: {
1347+
name: 'test-server',
1348+
version: '1.0.0'
1349+
}
1350+
}));
1351+
1352+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
1353+
tools: []
1354+
}));
1355+
1356+
const client = new Client({
1357+
name: 'test-client',
1358+
version: '1.0.0',
1359+
}, {
1360+
toolListChangedOptions: {
1361+
autoRefresh: false,
1362+
onToolListChanged: (err, tools) => {
1363+
notifications.push([err, tools]);
1364+
}
1365+
}
1366+
});
1367+
1368+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
1369+
1370+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
1371+
1372+
const result1 = await client.listTools();
1373+
expect(result1.tools).toHaveLength(0);
1374+
1375+
// Update the tools list
1376+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
1377+
tools: [
1378+
{
1379+
name: 'test-tool',
1380+
description: 'A test tool',
1381+
inputSchema: {
1382+
type: 'object',
1383+
properties: {}
1384+
}
1385+
// No outputSchema
1386+
}
1387+
]
1388+
}));
1389+
await server.sendToolListChanged();
1390+
1391+
// Wait for the debounced notifications to be processed
1392+
await new Promise(resolve => setTimeout(resolve, 1000));
1393+
1394+
// Should be 1 notification with no tool data because autoRefresh is false
1395+
expect(notifications).toHaveLength(1);
1396+
expect(notifications[0][0]).toBeNull();
1397+
expect(notifications[0][1]).toBeNull();
1398+
});
1399+
12321400
describe('outputSchema validation', () => {
12331401
/***
12341402
* Test: Validate structuredContent Against outputSchema

src/client/index.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ import {
4343
CreateTaskResultSchema,
4444
CreateMessageRequestSchema,
4545
CreateMessageResultSchema
46+
ToolListChangedNotificationSchema,
47+
ToolListChangedOptions
4648
} from '../types.js';
4749
import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js';
4850
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js';
@@ -163,6 +165,41 @@ export type ClientOptions = ProtocolOptions & {
163165
* ```
164166
*/
165167
jsonSchemaValidator?: jsonSchemaValidator;
168+
169+
/**
170+
* Configure automatic refresh behavior for tool list changed notifications
171+
*
172+
* @example
173+
* ```ts
174+
* {
175+
* autoRefresh: true,
176+
* debounceMs: 300,
177+
* onToolListChanged: (err, tools) => {
178+
* if (err) {
179+
* console.error('Failed to refresh tool list:', err);
180+
* return;
181+
* }
182+
* // Use the updated tool list
183+
* console.log('Tool list changed:', tools);
184+
* }
185+
* }
186+
* ```
187+
*
188+
* @example
189+
* ```ts
190+
* {
191+
* autoRefresh: false,
192+
* onToolListChanged: (err, tools) => {
193+
* // err is always null when autoRefresh is false
194+
*
195+
* // Manually refresh the tool list
196+
* const result = await this.listTools();
197+
* console.log('Tool list changed:', result.tools);
198+
* }
199+
* }
200+
* ```
201+
*/
202+
toolListChangedOptions?: ToolListChangedOptions;
166203
};
167204

168205
/**
@@ -204,6 +241,8 @@ export class Client<
204241
private _cachedKnownTaskTools: Set<string> = new Set();
205242
private _cachedRequiredTaskTools: Set<string> = new Set();
206243
private _experimental?: { tasks: ExperimentalClientTasks<RequestT, NotificationT, ResultT> };
244+
private _toolListChangedOptions: ToolListChangedOptions | null = null;
245+
private _toolListChangedDebounceTimer?: ReturnType<typeof setTimeout>;
207246

208247
/**
209248
* Initializes this client with the given name and version information.
@@ -215,6 +254,9 @@ export class Client<
215254
super(options);
216255
this._capabilities = options?.capabilities ?? {};
217256
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator();
257+
258+
// Set up tool list changed options
259+
this.setToolListChangedOptions(options?.toolListChangedOptions || null);
218260
}
219261

220262
/**
@@ -757,6 +799,60 @@ export class Client<
757799
return result;
758800
}
759801

802+
/**
803+
* Updates the tool list changed options
804+
*
805+
* Set to null to disable tool list changed notifications
806+
*/
807+
public setToolListChangedOptions(options: ToolListChangedOptions | null): void {
808+
// Set up tool list changed options and add notification handler
809+
if (options) {
810+
const toolListChangedOptions: ToolListChangedOptions = {
811+
autoRefresh: !!options.autoRefresh,
812+
debounceMs: options.debounceMs ?? 300,
813+
onToolListChanged: options.onToolListChanged,
814+
};
815+
this._toolListChangedOptions = toolListChangedOptions;
816+
this.setNotificationHandler(ToolListChangedNotificationSchema, () => {
817+
// If autoRefresh is false, call the callback for the notification, but without tools data
818+
if (!toolListChangedOptions.autoRefresh) {
819+
toolListChangedOptions.onToolListChanged?.(null, null);
820+
return;
821+
}
822+
823+
// Clear any pending debounce timer
824+
if (this._toolListChangedDebounceTimer) {
825+
clearTimeout(this._toolListChangedDebounceTimer);
826+
}
827+
828+
// Set up debounced refresh
829+
this._toolListChangedDebounceTimer = setTimeout(async () => {
830+
let tools: Tool[] | null = null;
831+
let error: Error | null = null;
832+
try {
833+
const result = await this.listTools();
834+
tools = result.tools;
835+
} catch (e) {
836+
error = e instanceof Error ? e : new Error(String(e));
837+
}
838+
toolListChangedOptions.onToolListChanged?.(error, tools);
839+
}, toolListChangedOptions.debounceMs);
840+
});
841+
}
842+
// Reset tool list changed options and remove notification handler
843+
else {
844+
this._toolListChangedOptions = null;
845+
this.removeNotificationHandler(ToolListChangedNotificationSchema.shape.method.value);
846+
}
847+
}
848+
849+
/**
850+
* Gets the current tool list changed options
851+
*/
852+
public getToolListChangedOptions(): ToolListChangedOptions | null {
853+
return this._toolListChangedOptions;
854+
}
855+
760856
async sendRootsListChanged() {
761857
return this.notification({ method: 'notifications/roots/list_changed' });
762858
}

src/types.ts

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

1384+
/**
1385+
* Client Options for tool list changed notifications.
1386+
*/
1387+
export const ToolListChangedOptionsSchema = z.object({
1388+
/**
1389+
* If true, the tool list will be refreshed automatically when a tool list changed notification is received.
1390+
*
1391+
* If `onToolListChanged` is also provided, it will be called after the tool list is auto refreshed.
1392+
*
1393+
* @default false
1394+
*/
1395+
autoRefresh: z.boolean().optional(),
1396+
/**
1397+
* Debounce time in milliseconds for tool list changed notification processing.
1398+
*
1399+
* Multiple notifications received within this timeframe will only trigger one refresh.
1400+
*
1401+
* @default 300
1402+
*/
1403+
debounceMs: z.number().int().optional(),
1404+
/**
1405+
* This callback is always called when the server sends a tool list changed notification.
1406+
*
1407+
* If `autoRefresh` is true, this callback will be called with updated tool list.
1408+
*/
1409+
onToolListChanged: z.function(z.tuple([z.instanceof(Error).nullable(), z.array(ToolSchema).nullable()]), z.void()),
1410+
});
1411+
1412+
export type ToolListChangedOptions = z.infer<typeof ToolListChangedOptionsSchema>;
1413+
13841414
/* Logging */
13851415
/**
13861416
* The severity of a log message.

0 commit comments

Comments
 (0)