list changed handlers on client constructor (#1206)
Co-authored-by: ChipGPT <chip@ignission.io> Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
This commit is contained in:
parent
724fe6eefe
commit
b0aac584fb
3 changed files with 767 additions and 2 deletions
|
|
@ -12,6 +12,7 @@ import {
|
|||
ListResourcesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ListToolsResultSchema,
|
||||
ListPromptsRequestSchema,
|
||||
CallToolRequestSchema,
|
||||
CallToolResultSchema,
|
||||
CreateMessageRequestSchema,
|
||||
|
|
@ -20,7 +21,10 @@ import {
|
|||
ListRootsRequestSchema,
|
||||
ErrorCode,
|
||||
McpError,
|
||||
CreateTaskResultSchema
|
||||
CreateTaskResultSchema,
|
||||
Tool,
|
||||
Prompt,
|
||||
Resource
|
||||
} from '../types.js';
|
||||
import { Transport } from '../shared/transport.js';
|
||||
import { Server } from '../server/index.js';
|
||||
|
|
@ -1229,6 +1233,551 @@ 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 McpServer({
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
// Register initial tool to enable the tools capability
|
||||
server.registerTool(
|
||||
'initial-tool',
|
||||
{
|
||||
description: 'Initial tool'
|
||||
},
|
||||
async () => ({ content: [] })
|
||||
);
|
||||
|
||||
// Configure listChanged handler in constructor
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
listChanged: {
|
||||
tools: {
|
||||
onChanged: (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(1);
|
||||
|
||||
// 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 2 tools because autoRefresh is true
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0][0]).toBeNull();
|
||||
expect(notifications[0][1]).toHaveLength(2);
|
||||
expect(notifications[0][1]?.[1].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 McpServer({
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
// 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',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
listChanged: {
|
||||
tools: {
|
||||
autoRefresh: false,
|
||||
debounceMs: 0,
|
||||
onChanged: (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(1);
|
||||
|
||||
// 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));
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
/***
|
||||
* 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 McpServer({
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
// Register initial prompt to enable the prompts capability
|
||||
server.registerPrompt(
|
||||
'initial-prompt',
|
||||
{
|
||||
description: 'Initial prompt'
|
||||
},
|
||||
async () => ({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }]
|
||||
})
|
||||
);
|
||||
|
||||
// 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(1);
|
||||
|
||||
// Register another prompt - this triggers listChanged notification
|
||||
server.registerPrompt('test-prompt', { description: 'A test prompt' }, async () => ({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }]
|
||||
}));
|
||||
|
||||
// Wait for the debounced notifications to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// 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(2);
|
||||
expect(notifications[0][1]?.[1].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 McpServer({
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
// 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
|
||||
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(1);
|
||||
|
||||
// Register another resource - this triggers listChanged notification
|
||||
server.registerResource('test-resource', 'file:///test.txt', {}, async () => ({
|
||||
contents: [{ uri: 'file:///test.txt', text: 'Hello' }]
|
||||
}));
|
||||
|
||||
// Wait for the debounced notifications to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// 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(2);
|
||||
expect(notifications[0][1]?.[1].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 McpServer({
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
// Register initial tool and prompt to enable capabilities
|
||||
server.registerTool(
|
||||
'tool-1',
|
||||
{
|
||||
description: 'Tool 1'
|
||||
},
|
||||
async () => ({ content: [] })
|
||||
);
|
||||
server.registerPrompt(
|
||||
'prompt-1',
|
||||
{
|
||||
description: 'Prompt 1'
|
||||
},
|
||||
async () => ({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }]
|
||||
})
|
||||
);
|
||||
|
||||
// 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)]);
|
||||
|
||||
// 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]).toHaveLength(2);
|
||||
|
||||
expect(promptNotifications).toHaveLength(1);
|
||||
expect(promptNotifications[0][1]).toHaveLength(2);
|
||||
});
|
||||
|
||||
/***
|
||||
* Test: Handler not activated when server doesn't advertise listChanged capability
|
||||
*/
|
||||
test('should not activate listChanged handler when server does not advertise capability', async () => {
|
||||
const notifications: [Error | null, Tool[] | null][] = [];
|
||||
|
||||
// Server with tools capability but WITHOUT listChanged
|
||||
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: {} } });
|
||||
|
||||
server.setRequestHandler(InitializeRequestSchema, async request => ({
|
||||
protocolVersion: request.params.protocolVersion,
|
||||
capabilities: { tools: {} }, // No listChanged: true
|
||||
serverInfo: { name: 'test-server', version: '1.0.0' }
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [{ name: 'test-tool', inputSchema: { type: 'object' } }]
|
||||
}));
|
||||
|
||||
// Configure listChanged handler that should NOT be activated
|
||||
const client = new Client(
|
||||
{ name: 'test-client', version: '1.0.0' },
|
||||
{
|
||||
listChanged: {
|
||||
tools: {
|
||||
debounceMs: 0,
|
||||
onChanged: (err, tools) => {
|
||||
notifications.push([err, tools]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
// Verify server doesn't have tools.listChanged capability
|
||||
expect(client.getServerCapabilities()?.tools?.listChanged).toBeFalsy();
|
||||
|
||||
// Send a tool list changed notification manually
|
||||
await server.notification({ method: 'notifications/tools/list_changed' });
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Handler should NOT have been activated because server didn't advertise listChanged
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
/***
|
||||
* Test: Handler activated when server advertises listChanged capability
|
||||
*/
|
||||
test('should activate listChanged handler when server advertises capability', async () => {
|
||||
const notifications: [Error | null, Tool[] | null][] = [];
|
||||
|
||||
// Server with tools.listChanged: true capability
|
||||
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } });
|
||||
|
||||
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: [{ name: 'test-tool', inputSchema: { type: 'object' } }]
|
||||
}));
|
||||
|
||||
// Configure listChanged handler that SHOULD be activated
|
||||
const client = new Client(
|
||||
{ name: 'test-client', version: '1.0.0' },
|
||||
{
|
||||
listChanged: {
|
||||
tools: {
|
||||
debounceMs: 0,
|
||||
onChanged: (err, tools) => {
|
||||
notifications.push([err, tools]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
// Verify server has tools.listChanged capability
|
||||
expect(client.getServerCapabilities()?.tools?.listChanged).toBe(true);
|
||||
|
||||
// Send a tool list changed notification
|
||||
await server.notification({ method: 'notifications/tools/list_changed' });
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Handler SHOULD have been called
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0][0]).toBeNull();
|
||||
expect(notifications[0][1]).toHaveLength(1);
|
||||
});
|
||||
|
||||
/***
|
||||
* Test: No handlers activated when server has no listChanged capabilities
|
||||
*/
|
||||
test('should not activate any handlers when server has no listChanged capabilities', async () => {
|
||||
const toolNotifications: [Error | null, Tool[] | null][] = [];
|
||||
const promptNotifications: [Error | null, Prompt[] | null][] = [];
|
||||
const resourceNotifications: [Error | null, Resource[] | null][] = [];
|
||||
|
||||
// Server with capabilities but NO listChanged for any
|
||||
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: {}, prompts: {}, resources: {} } });
|
||||
|
||||
server.setRequestHandler(InitializeRequestSchema, async request => ({
|
||||
protocolVersion: request.params.protocolVersion,
|
||||
capabilities: { tools: {}, prompts: {}, resources: {} },
|
||||
serverInfo: { name: 'test-server', version: '1.0.0' }
|
||||
}));
|
||||
|
||||
// Configure listChanged handlers for all three types
|
||||
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])
|
||||
},
|
||||
resources: {
|
||||
debounceMs: 0,
|
||||
onChanged: (err, resources) => resourceNotifications.push([err, resources])
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
// Verify server has no listChanged capabilities
|
||||
const caps = client.getServerCapabilities();
|
||||
expect(caps?.tools?.listChanged).toBeFalsy();
|
||||
expect(caps?.prompts?.listChanged).toBeFalsy();
|
||||
expect(caps?.resources?.listChanged).toBeFalsy();
|
||||
|
||||
// Send notifications for all three types
|
||||
await server.notification({ method: 'notifications/tools/list_changed' });
|
||||
await server.notification({ method: 'notifications/prompts/list_changed' });
|
||||
await server.notification({ method: 'notifications/resources/list_changed' });
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// No handlers should have been activated
|
||||
expect(toolNotifications).toHaveLength(0);
|
||||
expect(promptNotifications).toHaveLength(0);
|
||||
expect(resourceNotifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
/***
|
||||
* Test: Partial capability support - some handlers activated, others not
|
||||
*/
|
||||
test('should handle partial listChanged capability support', async () => {
|
||||
const toolNotifications: [Error | null, Tool[] | null][] = [];
|
||||
const promptNotifications: [Error | null, Prompt[] | null][] = [];
|
||||
|
||||
// Server with tools.listChanged: true but prompts without listChanged
|
||||
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: { listChanged: true }, prompts: {} } });
|
||||
|
||||
server.setRequestHandler(InitializeRequestSchema, async request => ({
|
||||
protocolVersion: request.params.protocolVersion,
|
||||
capabilities: { tools: { listChanged: true }, prompts: {} },
|
||||
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' }]
|
||||
}));
|
||||
|
||||
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)]);
|
||||
|
||||
// Verify capability state
|
||||
expect(client.getServerCapabilities()?.tools?.listChanged).toBe(true);
|
||||
expect(client.getServerCapabilities()?.prompts?.listChanged).toBeFalsy();
|
||||
|
||||
// Send notifications for both
|
||||
await server.notification({ method: 'notifications/tools/list_changed' });
|
||||
await server.notification({ method: 'notifications/prompts/list_changed' });
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Tools handler should have been called
|
||||
expect(toolNotifications).toHaveLength(1);
|
||||
// Prompts handler should NOT have been called (no prompts.listChanged)
|
||||
expect(promptNotifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('outputSchema validation', () => {
|
||||
/***
|
||||
* Test: Validate structuredContent Against outputSchema
|
||||
|
|
|
|||
|
|
@ -42,7 +42,13 @@ import {
|
|||
ElicitRequestSchema,
|
||||
CreateTaskResultSchema,
|
||||
CreateMessageRequestSchema,
|
||||
CreateMessageResultSchema
|
||||
CreateMessageResultSchema,
|
||||
ToolListChangedNotificationSchema,
|
||||
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';
|
||||
|
|
@ -163,6 +169,34 @@ export type ClientOptions = ProtocolOptions & {
|
|||
* ```
|
||||
*/
|
||||
jsonSchemaValidator?: jsonSchemaValidator;
|
||||
|
||||
/**
|
||||
* Configure handlers for list changed notifications (tools, prompts, resources).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* 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)
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
listChanged?: ListChangedHandlers;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -204,6 +238,8 @@ export class Client<
|
|||
private _cachedKnownTaskTools: Set<string> = new Set();
|
||||
private _cachedRequiredTaskTools: Set<string> = new Set();
|
||||
private _experimental?: { tasks: ExperimentalClientTasks<RequestT, NotificationT, ResultT> };
|
||||
private _listChangedDebounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||||
private _pendingListChangedConfig?: ListChangedHandlers;
|
||||
|
||||
/**
|
||||
* Initializes this client with the given name and version information.
|
||||
|
|
@ -215,6 +251,40 @@ export class Client<
|
|||
super(options);
|
||||
this._capabilities = options?.capabilities ?? {};
|
||||
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator();
|
||||
|
||||
// Store list changed config for setup after connection (when we know server capabilities)
|
||||
if (options?.listChanged) {
|
||||
this._pendingListChangedConfig = options.listChanged;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up handlers for list changed notifications based on config and server capabilities.
|
||||
* This should only be called after initialization when server capabilities are known.
|
||||
* Handlers are silently skipped if the server doesn't advertise the corresponding listChanged capability.
|
||||
* @internal
|
||||
*/
|
||||
private _setupListChangedHandlers(config: ListChangedHandlers): void {
|
||||
if (config.tools && this._serverCapabilities?.tools?.listChanged) {
|
||||
this._setupListChangedHandler('tools', ToolListChangedNotificationSchema, config.tools, async () => {
|
||||
const result = await this.listTools();
|
||||
return result.tools;
|
||||
});
|
||||
}
|
||||
|
||||
if (config.prompts && this._serverCapabilities?.prompts?.listChanged) {
|
||||
this._setupListChangedHandler('prompts', PromptListChangedNotificationSchema, config.prompts, async () => {
|
||||
const result = await this.listPrompts();
|
||||
return result.prompts;
|
||||
});
|
||||
}
|
||||
|
||||
if (config.resources && this._serverCapabilities?.resources?.listChanged) {
|
||||
this._setupListChangedHandler('resources', ResourceListChangedNotificationSchema, config.resources, async () => {
|
||||
const result = await this.listResources();
|
||||
return result.resources;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -442,6 +512,12 @@ export class Client<
|
|||
await this.notification({
|
||||
method: 'notifications/initialized'
|
||||
});
|
||||
|
||||
// Set up list changed handlers now that we know server capabilities
|
||||
if (this._pendingListChangedConfig) {
|
||||
this._setupListChangedHandlers(this._pendingListChangedConfig);
|
||||
this._pendingListChangedConfig = undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
// Disconnect if initialization fails.
|
||||
void this.close();
|
||||
|
|
@ -757,6 +833,66 @@ export class Client<
|
|||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a single list changed handler.
|
||||
* @internal
|
||||
*/
|
||||
private _setupListChangedHandler<T>(
|
||||
listType: string,
|
||||
notificationSchema: { shape: { method: { value: string } } },
|
||||
options: ListChangedOptions<T>,
|
||||
fetcher: () => Promise<T[]>
|
||||
): 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}`);
|
||||
}
|
||||
|
||||
// Validate callback
|
||||
if (typeof options.onChanged !== 'function') {
|
||||
throw new Error(`Invalid ${listType} listChanged options: onChanged must be a function`);
|
||||
}
|
||||
|
||||
const { autoRefresh, debounceMs } = parseResult.data;
|
||||
const { onChanged } = options;
|
||||
|
||||
const refresh = async () => {
|
||||
if (!autoRefresh) {
|
||||
onChanged(null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Set up debounced refresh
|
||||
const timer = setTimeout(refresh, debounceMs);
|
||||
this._listChangedDebounceTimers.set(listType, timer);
|
||||
} else {
|
||||
// No debounce, refresh immediately
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
|
||||
// Register notification handler
|
||||
this.setNotificationHandler(notificationSchema as AnyObjectSchema, handler);
|
||||
}
|
||||
|
||||
async sendRootsListChanged() {
|
||||
return this.notification({ method: 'notifications/roots/list_changed' });
|
||||
}
|
||||
|
|
|
|||
80
src/types.ts
80
src/types.ts
|
|
@ -1430,6 +1430,86 @@ export const ToolListChangedNotificationSchema = NotificationSchema.extend({
|
|||
method: z.literal('notifications/tools/list_changed')
|
||||
});
|
||||
|
||||
/**
|
||||
* Callback type for list changed notifications.
|
||||
*/
|
||||
export type ListChangedCallback<T> = (error: Error | null, items: T[] | null) => void;
|
||||
|
||||
/**
|
||||
* Base schema for list changed subscription options (without callback).
|
||||
* Used internally for Zod validation of autoRefresh and debounceMs.
|
||||
*/
|
||||
export const ListChangedOptionsBaseSchema = z.object({
|
||||
/**
|
||||
* 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 false, the callback will be called with null items, allowing manual refresh.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
autoRefresh: z.boolean().default(true),
|
||||
/**
|
||||
* 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)
|
||||
});
|
||||
|
||||
/**
|
||||
* Options for subscribing to list changed notifications.
|
||||
*
|
||||
* @typeParam T - The type of items in the list (Tool, Prompt, or Resource)
|
||||
*/
|
||||
export type ListChangedOptions<T> = {
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Callback invoked when the list changes.
|
||||
*
|
||||
* If autoRefresh is true, items contains the updated list.
|
||||
* If autoRefresh is false, items is null (caller should refresh manually).
|
||||
*/
|
||||
onChanged: ListChangedCallback<T>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for list changed notification handlers.
|
||||
*
|
||||
* Use this to configure handlers for tools, prompts, and resources list changes
|
||||
* when creating a client.
|
||||
*
|
||||
* Note: Handlers are only activated if the server advertises the corresponding
|
||||
* `listChanged` capability (e.g., `tools.listChanged: true`). If the server
|
||||
* doesn't advertise this capability, the handler will not be set up.
|
||||
*/
|
||||
export type ListChangedHandlers = {
|
||||
/**
|
||||
* Handler for tool list changes.
|
||||
*/
|
||||
tools?: ListChangedOptions<Tool>;
|
||||
/**
|
||||
* Handler for prompt list changes.
|
||||
*/
|
||||
prompts?: ListChangedOptions<Prompt>;
|
||||
/**
|
||||
* Handler for resource list changes.
|
||||
*/
|
||||
resources?: ListChangedOptions<Resource>;
|
||||
};
|
||||
|
||||
/* Logging */
|
||||
/**
|
||||
* The severity of a log message.
|
||||
|
|
|
|||
Loading…
Reference in a new issue