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:
Matt 2025-12-04 19:08:36 +00:00 • committed by GitHub
commit b0aac584fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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