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
1 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue