Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 175 additions & 1 deletion src/client/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import {
ListRootsRequestSchema,
ErrorCode,
McpError,
CreateTaskResultSchema
CreateTaskResultSchema,
Tool
} from '../types.js';
import { Transport } from '../shared/transport.js';
import { Server } from '../server/index.js';
Expand Down Expand Up @@ -1229,6 +1230,179 @@ test('should handle request timeout', async () => {
});
});

/***
* Test: Handle Tool List Changed Notifications with Auto Refresh
*/
test('should handle tool list changed notification with auto refresh', async () => {
// List changed notifications
const notifications: [Error | null, Tool[] | null][] = [];

const server = new Server(
{
name: 'test-server',
version: '1.0.0'
},
{
capabilities: {
tools: {
listChanged: true
}
}
}
);

// Set up server handlers
server.setRequestHandler(InitializeRequestSchema, async request => ({
protocolVersion: request.params.protocolVersion,
capabilities: {
tools: {
listChanged: true
}
},
serverInfo: {
name: 'test-server',
version: '1.0.0'
}
}));

server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: []
}));

const client = new Client(
{
name: 'test-client',
version: '1.0.0'
},
{
toolListChangedOptions: {
onToolListChanged: (err, tools) => {
notifications.push([err, tools]);
}
}
}
);

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

const result1 = await client.listTools();
expect(result1.tools).toHaveLength(0);

// Update the tools list
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'test-tool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: {}
}
// No outputSchema
}
]
}));
await server.sendToolListChanged();

// Wait for the debounced notifications to be processed
await new Promise(resolve => setTimeout(resolve, 1000));

// Should be 1 notification with 1 tool because autoRefresh is true
expect(notifications).toHaveLength(1);
expect(notifications[0][0]).toBeNull();
expect(notifications[0][1]).toHaveLength(1);
expect(notifications[0][1]?.[0].name).toBe('test-tool');
});

/***
* Test: Handle Tool List Changed Notifications with Manual Refresh
*/
test('should handle tool list changed notification with manual refresh', async () => {
// List changed notifications
const notifications: [Error | null, Tool[] | null][] = [];

const server = new Server(
{
name: 'test-server',
version: '1.0.0'
},
{
capabilities: {
tools: {
listChanged: true
}
}
}
);

// Set up server handlers
server.setRequestHandler(InitializeRequestSchema, async request => ({
protocolVersion: request.params.protocolVersion,
capabilities: {
tools: {
listChanged: true
}
},
serverInfo: {
name: 'test-server',
version: '1.0.0'
}
}));

server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: []
}));

const client = new Client(
{
name: 'test-client',
version: '1.0.0'
},
{
toolListChangedOptions: {
autoRefresh: false,
debounceMs: 0,
onToolListChanged: (err, tools) => {
notifications.push([err, tools]);
}
}
}
);

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

const result1 = await client.listTools();
expect(result1.tools).toHaveLength(0);

// Update the tools list
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'test-tool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: {}
}
// No outputSchema
}
]
}));
await server.sendToolListChanged();

// Wait for the debounced notifications to be processed
await new Promise(resolve => setTimeout(resolve, 1000));

// Should be 1 notification with no tool data because autoRefresh is false
expect(notifications).toHaveLength(1);
expect(notifications[0][0]).toBeNull();
expect(notifications[0][1]).toBeNull();
});

describe('outputSchema validation', () => {
/***
* Test: Validate structuredContent Against outputSchema
Expand Down
116 changes: 115 additions & 1 deletion src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ import {
ElicitRequestSchema,
CreateTaskResultSchema,
CreateMessageRequestSchema,
CreateMessageResultSchema
CreateMessageResultSchema,
ToolListChangedNotificationSchema,
ToolListChangedOptions,
ToolListChangedOptionsSchema
} from '../types.js';
import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js';
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js';
Expand Down Expand Up @@ -163,6 +166,44 @@ export type ClientOptions = ProtocolOptions & {
* ```
*/
jsonSchemaValidator?: jsonSchemaValidator;

/**
* Configure automatic refresh behavior for tool list changed notifications
*
* Here's an example of how to get the updated tool list when the tool list changed notification is received:
*
* @example
* ```typescript
* {
* onToolListChanged: (err, tools) => {
* if (err) {
* console.error('Failed to refresh tool list:', err);
* return;
* }
* // Use the updated tool list
* console.log('Tool list changed:', tools);
* }
* }
* ```
*
* Here is an example of how to manually refresh the tool list when the tool list changed notification is received:
*
* @example
* ```typescript
* {
* autoRefresh: false,
* debounceMs: 0,
* onToolListChanged: (err, tools) => {
* // err is always null when autoRefresh is false
*
* // Manually refresh the tool list
* const result = await this.listTools();
* console.log('Tool list changed:', result.tools);
* }
* }
* ```
*/
toolListChangedOptions?: ToolListChangedOptions;
};

/**
Expand Down Expand Up @@ -204,6 +245,8 @@ export class Client<
private _cachedKnownTaskTools: Set<string> = new Set();
private _cachedRequiredTaskTools: Set<string> = new Set();
private _experimental?: { tasks: ExperimentalClientTasks<RequestT, NotificationT, ResultT> };
private _toolListChangedOptions?: ToolListChangedOptions;
private _toolListChangedDebounceTimer?: ReturnType<typeof setTimeout>;

/**
* Initializes this client with the given name and version information.
Expand All @@ -215,6 +258,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);
}

/**
Expand Down Expand Up @@ -757,6 +803,74 @@ 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) {
// 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;

const refreshToolList = async () => {
// If autoRefresh is false, call the callback for the notification, but without tools data
if (!autoRefresh) {
onToolListChanged(null, null);
return;
}

let tools: Tool[] | null = null;
let error: Error | null = null;
try {
const result = await this.listTools();
tools = result.tools;
} catch (e) {
error = e instanceof Error ? e : new Error(String(e));
}
onToolListChanged(error, tools);
};

this.setNotificationHandler(ToolListChangedNotificationSchema, () => {
if (debounceMs) {
// Clear any pending debounce timer
if (this._toolListChangedDebounceTimer) {
clearTimeout(this._toolListChangedDebounceTimer);
}

// Set up debounced refresh
this._toolListChangedDebounceTimer = setTimeout(refreshToolList, debounceMs);
} else {
// No debounce, refresh immediately
refreshToolList();
}
});
}
// Reset tool list changed options and remove notification handler
else {
this._toolListChangedOptions = undefined;
this.removeNotificationHandler(ToolListChangedNotificationSchema.shape.method.value);
if (this._toolListChangedDebounceTimer) {
clearTimeout(this._toolListChangedDebounceTimer);
this._toolListChangedDebounceTimer = undefined;
}
}
}

/**
* Gets the current tool list changed options
*/
public getToolListChangedOptions(): ToolListChangedOptions | undefined {
return this._toolListChangedOptions;
}

async sendRootsListChanged() {
return this.notification({ method: 'notifications/roots/list_changed' });
}
Expand Down
37 changes: 37 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1381,6 +1381,43 @@ 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.
*/
export const ToolListChangedOptionsSchema = z.object({
/**
* If true, the tool list will be refreshed automatically when a tool list changed notification is received.
*
* If `onToolListChanged` is also provided, it will be called after the tool list is auto refreshed.
*
* @default true
*/
autoRefresh: z.boolean().default(true),
/**
* Debounce time in milliseconds for tool list changed notification processing.
*
* Multiple notifications received within this timeframe will only trigger one refresh.
*
* @default 300
*/
debounceMs: z.number().int().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.custom<ToolListChangedCallback>((val): val is ToolListChangedCallback => typeof val === 'function', {
message: 'onToolListChanged must be a function'
})
});

export type ToolListChangedOptions = z.input<typeof ToolListChangedOptionsSchema>;

/* Logging */
/**
* The severity of a log message.
Expand Down
Loading