4465 lines
138 KiB
TypeScript
4465 lines
138 KiB
TypeScript
import { z } from 'zod';
|
|
import { Client } from '../client/index.js';
|
|
import { InMemoryTransport } from '../inMemory.js';
|
|
import { getDisplayName } from '../shared/metadataUtils.js';
|
|
import { UriTemplate } from '../shared/uriTemplate.js';
|
|
import {
|
|
CallToolResultSchema,
|
|
CompleteResultSchema,
|
|
ElicitRequestSchema,
|
|
GetPromptResultSchema,
|
|
ListPromptsResultSchema,
|
|
ListResourcesResultSchema,
|
|
ListResourceTemplatesResultSchema,
|
|
ListToolsResultSchema,
|
|
LoggingMessageNotificationSchema,
|
|
type Notification,
|
|
ReadResourceResultSchema,
|
|
type TextContent
|
|
} from '../types.js';
|
|
import { completable } from './completable.js';
|
|
import { McpServer, ResourceTemplate } from './mcp.js';
|
|
|
|
describe('McpServer', () => {
|
|
/***
|
|
* Test: Basic Server Instance
|
|
*/
|
|
test('should expose underlying Server instance', () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
expect(mcpServer.server).toBeDefined();
|
|
});
|
|
|
|
/***
|
|
* Test: Notification Sending via Server
|
|
*/
|
|
test('should allow sending notifications via Server', async () => {
|
|
const mcpServer = new McpServer(
|
|
{
|
|
name: 'test server',
|
|
version: '1.0'
|
|
},
|
|
{ capabilities: { logging: {} } }
|
|
);
|
|
|
|
const notifications: Notification[] = [];
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
client.fallbackNotificationHandler = async notification => {
|
|
notifications.push(notification);
|
|
};
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
// This should work because we're using the underlying server
|
|
await expect(
|
|
mcpServer.server.sendLoggingMessage({
|
|
level: 'info',
|
|
data: 'Test log message'
|
|
})
|
|
).resolves.not.toThrow();
|
|
|
|
expect(notifications).toMatchObject([
|
|
{
|
|
method: 'notifications/message',
|
|
params: {
|
|
level: 'info',
|
|
data: 'Test log message'
|
|
}
|
|
}
|
|
]);
|
|
});
|
|
|
|
/***
|
|
* Test: Progress Notification with Message Field
|
|
*/
|
|
test('should send progress notifications with message field', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
// Create a tool that sends progress updates
|
|
mcpServer.tool(
|
|
'long-operation',
|
|
'A long running operation with progress updates',
|
|
{
|
|
steps: z.number().min(1).describe('Number of steps to perform')
|
|
},
|
|
async ({ steps }, { sendNotification, _meta }) => {
|
|
const progressToken = _meta?.progressToken;
|
|
|
|
if (progressToken) {
|
|
// Send progress notification for each step
|
|
for (let i = 1; i <= steps; i++) {
|
|
await sendNotification({
|
|
method: 'notifications/progress',
|
|
params: {
|
|
progressToken,
|
|
progress: i,
|
|
total: steps,
|
|
message: `Completed step ${i} of ${steps}`
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text' as const,
|
|
text: `Operation completed with ${steps} steps`
|
|
}
|
|
]
|
|
};
|
|
}
|
|
);
|
|
|
|
const progressUpdates: Array<{
|
|
progress: number;
|
|
total?: number;
|
|
message?: string;
|
|
}> = [];
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
// Call the tool with progress tracking
|
|
await client.request(
|
|
{
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'long-operation',
|
|
arguments: { steps: 3 },
|
|
_meta: {
|
|
progressToken: 'progress-test-1'
|
|
}
|
|
}
|
|
},
|
|
CallToolResultSchema,
|
|
{
|
|
onprogress: progress => {
|
|
progressUpdates.push(progress);
|
|
}
|
|
}
|
|
);
|
|
|
|
// Verify progress notifications were received with message field
|
|
expect(progressUpdates).toHaveLength(3);
|
|
expect(progressUpdates[0]).toMatchObject({
|
|
progress: 1,
|
|
total: 3,
|
|
message: 'Completed step 1 of 3'
|
|
});
|
|
expect(progressUpdates[1]).toMatchObject({
|
|
progress: 2,
|
|
total: 3,
|
|
message: 'Completed step 2 of 3'
|
|
});
|
|
expect(progressUpdates[2]).toMatchObject({
|
|
progress: 3,
|
|
total: 3,
|
|
message: 'Completed step 3 of 3'
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('ResourceTemplate', () => {
|
|
/***
|
|
* Test: ResourceTemplate Creation with String Pattern
|
|
*/
|
|
test('should create ResourceTemplate with string pattern', () => {
|
|
const template = new ResourceTemplate('test://{category}/{id}', {
|
|
list: undefined
|
|
});
|
|
expect(template.uriTemplate.toString()).toBe('test://{category}/{id}');
|
|
expect(template.listCallback).toBeUndefined();
|
|
});
|
|
|
|
/***
|
|
* Test: ResourceTemplate Creation with UriTemplate Instance
|
|
*/
|
|
test('should create ResourceTemplate with UriTemplate', () => {
|
|
const uriTemplate = new UriTemplate('test://{category}/{id}');
|
|
const template = new ResourceTemplate(uriTemplate, { list: undefined });
|
|
expect(template.uriTemplate).toBe(uriTemplate);
|
|
expect(template.listCallback).toBeUndefined();
|
|
});
|
|
|
|
/***
|
|
* Test: ResourceTemplate with List Callback
|
|
*/
|
|
test('should create ResourceTemplate with list callback', async () => {
|
|
const list = jest.fn().mockResolvedValue({
|
|
resources: [{ name: 'Test', uri: 'test://example' }]
|
|
});
|
|
|
|
const template = new ResourceTemplate('test://{id}', { list });
|
|
expect(template.listCallback).toBe(list);
|
|
|
|
const abortController = new AbortController();
|
|
const result = await template.listCallback?.({
|
|
signal: abortController.signal,
|
|
requestId: 'not-implemented',
|
|
sendRequest: () => {
|
|
throw new Error('Not implemented');
|
|
},
|
|
sendNotification: () => {
|
|
throw new Error('Not implemented');
|
|
}
|
|
});
|
|
expect(result?.resources).toHaveLength(1);
|
|
expect(list).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('tool()', () => {
|
|
/***
|
|
* Test: Zero-Argument Tool Registration
|
|
*/
|
|
test('should register zero-argument tool', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const notifications: Notification[] = [];
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
client.fallbackNotificationHandler = async notification => {
|
|
notifications.push(notification);
|
|
};
|
|
|
|
mcpServer.tool('test', async () => ({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'Test response'
|
|
}
|
|
]
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'tools/list'
|
|
},
|
|
ListToolsResultSchema
|
|
);
|
|
|
|
expect(result.tools).toHaveLength(1);
|
|
expect(result.tools[0].name).toBe('test');
|
|
expect(result.tools[0].inputSchema).toEqual({
|
|
type: 'object',
|
|
properties: {}
|
|
});
|
|
|
|
// Adding the tool before the connection was established means no notification was sent
|
|
expect(notifications).toHaveLength(0);
|
|
|
|
// Adding another tool triggers the update notification
|
|
mcpServer.tool('test2', async () => ({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'Test response'
|
|
}
|
|
]
|
|
}));
|
|
|
|
// Yield event loop to let the notification fly
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(notifications).toMatchObject([
|
|
{
|
|
method: 'notifications/tools/list_changed'
|
|
}
|
|
]);
|
|
});
|
|
|
|
/***
|
|
* Test: Updating Existing Tool
|
|
*/
|
|
test('should update existing tool', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const notifications: Notification[] = [];
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
client.fallbackNotificationHandler = async notification => {
|
|
notifications.push(notification);
|
|
};
|
|
|
|
// Register initial tool
|
|
const tool = mcpServer.tool('test', async () => ({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'Initial response'
|
|
}
|
|
]
|
|
}));
|
|
|
|
// Update the tool
|
|
tool.update({
|
|
callback: async () => ({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'Updated response'
|
|
}
|
|
]
|
|
})
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
|
|
|
// Call the tool and verify we get the updated response
|
|
const result = await client.request(
|
|
{
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'test'
|
|
}
|
|
},
|
|
CallToolResultSchema
|
|
);
|
|
|
|
expect(result.content).toEqual([
|
|
{
|
|
type: 'text',
|
|
text: 'Updated response'
|
|
}
|
|
]);
|
|
|
|
// Update happened before transport was connected, so no notifications should be expected
|
|
expect(notifications).toHaveLength(0);
|
|
});
|
|
|
|
/***
|
|
* Test: Updating Tool with Schema
|
|
*/
|
|
test('should update tool with schema', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const notifications: Notification[] = [];
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
client.fallbackNotificationHandler = async notification => {
|
|
notifications.push(notification);
|
|
};
|
|
|
|
// Register initial tool
|
|
const tool = mcpServer.tool(
|
|
'test',
|
|
{
|
|
name: z.string()
|
|
},
|
|
async ({ name }) => ({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `Initial: ${name}`
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
// Update the tool with a different schema
|
|
tool.update({
|
|
paramsSchema: {
|
|
name: z.string(),
|
|
value: z.number()
|
|
},
|
|
callback: async ({ name, value }) => ({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `Updated: ${name}, ${value}`
|
|
}
|
|
]
|
|
})
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
|
|
|
// Verify the schema was updated
|
|
const listResult = await client.request(
|
|
{
|
|
method: 'tools/list'
|
|
},
|
|
ListToolsResultSchema
|
|
);
|
|
|
|
expect(listResult.tools[0].inputSchema).toMatchObject({
|
|
properties: {
|
|
name: { type: 'string' },
|
|
value: { type: 'number' }
|
|
}
|
|
});
|
|
|
|
// Call the tool with the new schema
|
|
const callResult = await client.request(
|
|
{
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'test',
|
|
arguments: {
|
|
name: 'test',
|
|
value: 42
|
|
}
|
|
}
|
|
},
|
|
CallToolResultSchema
|
|
);
|
|
|
|
expect(callResult.content).toEqual([
|
|
{
|
|
type: 'text',
|
|
text: 'Updated: test, 42'
|
|
}
|
|
]);
|
|
|
|
// Update happened before transport was connected, so no notifications should be expected
|
|
expect(notifications).toHaveLength(0);
|
|
});
|
|
|
|
/***
|
|
* Test: Tool List Changed Notifications
|
|
*/
|
|
test('should send tool list changed notifications when connected', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const notifications: Notification[] = [];
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
client.fallbackNotificationHandler = async notification => {
|
|
notifications.push(notification);
|
|
};
|
|
|
|
// Register initial tool
|
|
const tool = mcpServer.tool('test', async () => ({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'Test response'
|
|
}
|
|
]
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
|
|
|
expect(notifications).toHaveLength(0);
|
|
|
|
// Now update the tool
|
|
tool.update({
|
|
callback: async () => ({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'Updated response'
|
|
}
|
|
]
|
|
})
|
|
});
|
|
|
|
// Yield event loop to let the notification fly
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(notifications).toMatchObject([{ method: 'notifications/tools/list_changed' }]);
|
|
|
|
// Now delete the tool
|
|
tool.remove();
|
|
|
|
// Yield event loop to let the notification fly
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(notifications).toMatchObject([
|
|
{ method: 'notifications/tools/list_changed' },
|
|
{ method: 'notifications/tools/list_changed' }
|
|
]);
|
|
});
|
|
|
|
/***
|
|
* Test: Tool Registration with Parameters
|
|
*/
|
|
test('should register tool with params', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
// old api
|
|
mcpServer.tool(
|
|
'test',
|
|
{
|
|
name: z.string(),
|
|
value: z.number()
|
|
},
|
|
async ({ name, value }) => ({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `${name}: ${value}`
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
// new api
|
|
mcpServer.registerTool(
|
|
'test (new api)',
|
|
{
|
|
inputSchema: { name: z.string(), value: z.number() }
|
|
},
|
|
async ({ name, value }) => ({
|
|
content: [{ type: 'text', text: `${name}: ${value}` }]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'tools/list'
|
|
},
|
|
ListToolsResultSchema
|
|
);
|
|
|
|
expect(result.tools).toHaveLength(2);
|
|
expect(result.tools[0].name).toBe('test');
|
|
expect(result.tools[0].inputSchema).toMatchObject({
|
|
type: 'object',
|
|
properties: {
|
|
name: { type: 'string' },
|
|
value: { type: 'number' }
|
|
}
|
|
});
|
|
expect(result.tools[1].name).toBe('test (new api)');
|
|
expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema);
|
|
});
|
|
|
|
/***
|
|
* Test: Tool Registration with Description
|
|
*/
|
|
test('should register tool with description', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
// old api
|
|
mcpServer.tool('test', 'Test description', async () => ({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'Test response'
|
|
}
|
|
]
|
|
}));
|
|
|
|
// new api
|
|
mcpServer.registerTool(
|
|
'test (new api)',
|
|
{
|
|
description: 'Test description'
|
|
},
|
|
async () => ({
|
|
content: [
|
|
{
|
|
type: 'text' as const,
|
|
text: 'Test response'
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'tools/list'
|
|
},
|
|
ListToolsResultSchema
|
|
);
|
|
|
|
expect(result.tools).toHaveLength(2);
|
|
expect(result.tools[0].name).toBe('test');
|
|
expect(result.tools[0].description).toBe('Test description');
|
|
expect(result.tools[1].name).toBe('test (new api)');
|
|
expect(result.tools[1].description).toBe('Test description');
|
|
});
|
|
|
|
/***
|
|
* Test: Tool Registration with Annotations
|
|
*/
|
|
test('should register tool with annotations', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.tool('test', { title: 'Test Tool', readOnlyHint: true }, async () => ({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'Test response'
|
|
}
|
|
]
|
|
}));
|
|
|
|
mcpServer.registerTool(
|
|
'test (new api)',
|
|
{
|
|
annotations: { title: 'Test Tool', readOnlyHint: true }
|
|
},
|
|
async () => ({
|
|
content: [
|
|
{
|
|
type: 'text' as const,
|
|
text: 'Test response'
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'tools/list'
|
|
},
|
|
ListToolsResultSchema
|
|
);
|
|
|
|
expect(result.tools).toHaveLength(2);
|
|
expect(result.tools[0].name).toBe('test');
|
|
expect(result.tools[0].annotations).toEqual({
|
|
title: 'Test Tool',
|
|
readOnlyHint: true
|
|
});
|
|
expect(result.tools[1].name).toBe('test (new api)');
|
|
expect(result.tools[1].annotations).toEqual({
|
|
title: 'Test Tool',
|
|
readOnlyHint: true
|
|
});
|
|
});
|
|
|
|
/***
|
|
* Test: Tool Registration with Parameters and Annotations
|
|
*/
|
|
test('should register tool with params and annotations', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.tool('test', { name: z.string() }, { title: 'Test Tool', readOnlyHint: true }, async ({ name }) => ({
|
|
content: [{ type: 'text', text: `Hello, ${name}!` }]
|
|
}));
|
|
|
|
mcpServer.registerTool(
|
|
'test (new api)',
|
|
{
|
|
inputSchema: { name: z.string() },
|
|
annotations: { title: 'Test Tool', readOnlyHint: true }
|
|
},
|
|
async ({ name }) => ({
|
|
content: [{ type: 'text', text: `Hello, ${name}!` }]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema);
|
|
|
|
expect(result.tools).toHaveLength(2);
|
|
expect(result.tools[0].name).toBe('test');
|
|
expect(result.tools[0].inputSchema).toMatchObject({
|
|
type: 'object',
|
|
properties: { name: { type: 'string' } }
|
|
});
|
|
expect(result.tools[0].annotations).toEqual({
|
|
title: 'Test Tool',
|
|
readOnlyHint: true
|
|
});
|
|
expect(result.tools[1].name).toBe('test (new api)');
|
|
expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema);
|
|
expect(result.tools[1].annotations).toEqual(result.tools[0].annotations);
|
|
});
|
|
|
|
/***
|
|
* Test: Tool Registration with Description, Parameters, and Annotations
|
|
*/
|
|
test('should register tool with description, params, and annotations', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.tool(
|
|
'test',
|
|
'A tool with everything',
|
|
{ name: z.string() },
|
|
{ title: 'Complete Test Tool', readOnlyHint: true, openWorldHint: false },
|
|
async ({ name }) => ({
|
|
content: [{ type: 'text', text: `Hello, ${name}!` }]
|
|
})
|
|
);
|
|
|
|
mcpServer.registerTool(
|
|
'test (new api)',
|
|
{
|
|
description: 'A tool with everything',
|
|
inputSchema: { name: z.string() },
|
|
annotations: {
|
|
title: 'Complete Test Tool',
|
|
readOnlyHint: true,
|
|
openWorldHint: false
|
|
}
|
|
},
|
|
async ({ name }) => ({
|
|
content: [{ type: 'text', text: `Hello, ${name}!` }]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema);
|
|
|
|
expect(result.tools).toHaveLength(2);
|
|
expect(result.tools[0].name).toBe('test');
|
|
expect(result.tools[0].description).toBe('A tool with everything');
|
|
expect(result.tools[0].inputSchema).toMatchObject({
|
|
type: 'object',
|
|
properties: { name: { type: 'string' } }
|
|
});
|
|
expect(result.tools[0].annotations).toEqual({
|
|
title: 'Complete Test Tool',
|
|
readOnlyHint: true,
|
|
openWorldHint: false
|
|
});
|
|
expect(result.tools[1].name).toBe('test (new api)');
|
|
expect(result.tools[1].description).toBe('A tool with everything');
|
|
expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema);
|
|
expect(result.tools[1].annotations).toEqual(result.tools[0].annotations);
|
|
});
|
|
|
|
/***
|
|
* Test: Tool Registration with Description, Empty Parameters, and Annotations
|
|
*/
|
|
test('should register tool with description, empty params, and annotations', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.tool(
|
|
'test',
|
|
'A tool with everything but empty params',
|
|
{},
|
|
{
|
|
title: 'Complete Test Tool with empty params',
|
|
readOnlyHint: true,
|
|
openWorldHint: false
|
|
},
|
|
async () => ({
|
|
content: [{ type: 'text', text: 'Test response' }]
|
|
})
|
|
);
|
|
|
|
mcpServer.registerTool(
|
|
'test (new api)',
|
|
{
|
|
description: 'A tool with everything but empty params',
|
|
inputSchema: {},
|
|
annotations: {
|
|
title: 'Complete Test Tool with empty params',
|
|
readOnlyHint: true,
|
|
openWorldHint: false
|
|
}
|
|
},
|
|
async () => ({
|
|
content: [{ type: 'text' as const, text: 'Test response' }]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema);
|
|
|
|
expect(result.tools).toHaveLength(2);
|
|
expect(result.tools[0].name).toBe('test');
|
|
expect(result.tools[0].description).toBe('A tool with everything but empty params');
|
|
expect(result.tools[0].inputSchema).toMatchObject({
|
|
type: 'object',
|
|
properties: {}
|
|
});
|
|
expect(result.tools[0].annotations).toEqual({
|
|
title: 'Complete Test Tool with empty params',
|
|
readOnlyHint: true,
|
|
openWorldHint: false
|
|
});
|
|
expect(result.tools[1].name).toBe('test (new api)');
|
|
expect(result.tools[1].description).toBe('A tool with everything but empty params');
|
|
expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema);
|
|
expect(result.tools[1].annotations).toEqual(result.tools[0].annotations);
|
|
});
|
|
|
|
/***
|
|
* Test: Tool Argument Validation
|
|
*/
|
|
test('should validate tool args', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.tool(
|
|
'test',
|
|
{
|
|
name: z.string(),
|
|
value: z.number()
|
|
},
|
|
async ({ name, value }) => ({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `${name}: ${value}`
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
mcpServer.registerTool(
|
|
'test (new api)',
|
|
{
|
|
inputSchema: {
|
|
name: z.string(),
|
|
value: z.number()
|
|
}
|
|
},
|
|
async ({ name, value }) => ({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `${name}: ${value}`
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'test',
|
|
arguments: {
|
|
name: 'test',
|
|
value: 'not a number'
|
|
}
|
|
}
|
|
},
|
|
CallToolResultSchema
|
|
);
|
|
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
type: 'text',
|
|
text: expect.stringContaining('Input validation error: Invalid arguments for tool test')
|
|
}
|
|
])
|
|
);
|
|
|
|
const result2 = await client.request(
|
|
{
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'test (new api)',
|
|
arguments: {
|
|
name: 'test',
|
|
value: 'not a number'
|
|
}
|
|
}
|
|
},
|
|
CallToolResultSchema
|
|
);
|
|
|
|
expect(result2.isError).toBe(true);
|
|
expect(result2.content).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
type: 'text',
|
|
text: expect.stringContaining('Input validation error: Invalid arguments for tool test (new api)')
|
|
}
|
|
])
|
|
);
|
|
});
|
|
|
|
/***
|
|
* Test: Preventing Duplicate Tool Registration
|
|
*/
|
|
test('should prevent duplicate tool registration', () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.tool('test', async () => ({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'Test response'
|
|
}
|
|
]
|
|
}));
|
|
|
|
expect(() => {
|
|
mcpServer.tool('test', async () => ({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'Test response 2'
|
|
}
|
|
]
|
|
}));
|
|
}).toThrow(/already registered/);
|
|
});
|
|
|
|
/***
|
|
* Test: Multiple Tool Registration
|
|
*/
|
|
test('should allow registering multiple tools', () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
// This should succeed
|
|
mcpServer.tool('tool1', () => ({ content: [] }));
|
|
|
|
// This should also succeed and not throw about request handlers
|
|
mcpServer.tool('tool2', () => ({ content: [] }));
|
|
});
|
|
|
|
/***
|
|
* Test: Tool with Output Schema and Structured Content
|
|
*/
|
|
test('should support tool with outputSchema and structuredContent', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
// Register a tool with outputSchema
|
|
mcpServer.registerTool(
|
|
'test',
|
|
{
|
|
description: 'Test tool with structured output',
|
|
inputSchema: {
|
|
input: z.string()
|
|
},
|
|
outputSchema: {
|
|
processedInput: z.string(),
|
|
resultType: z.string(),
|
|
timestamp: z.string()
|
|
}
|
|
},
|
|
async ({ input }) => ({
|
|
structuredContent: {
|
|
processedInput: input,
|
|
resultType: 'structured',
|
|
timestamp: '2023-01-01T00:00:00Z'
|
|
},
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: JSON.stringify({
|
|
processedInput: input,
|
|
resultType: 'structured',
|
|
timestamp: '2023-01-01T00:00:00Z'
|
|
})
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
// Verify the tool registration includes outputSchema
|
|
const listResult = await client.request(
|
|
{
|
|
method: 'tools/list'
|
|
},
|
|
ListToolsResultSchema
|
|
);
|
|
|
|
expect(listResult.tools).toHaveLength(1);
|
|
expect(listResult.tools[0].outputSchema).toMatchObject({
|
|
type: 'object',
|
|
properties: {
|
|
processedInput: { type: 'string' },
|
|
resultType: { type: 'string' },
|
|
timestamp: { type: 'string' }
|
|
},
|
|
required: ['processedInput', 'resultType', 'timestamp']
|
|
});
|
|
|
|
// Call the tool and verify it returns valid structuredContent
|
|
const result = await client.request(
|
|
{
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'test',
|
|
arguments: {
|
|
input: 'hello'
|
|
}
|
|
}
|
|
},
|
|
CallToolResultSchema
|
|
);
|
|
|
|
expect(result.structuredContent).toBeDefined();
|
|
const structuredContent = result.structuredContent as {
|
|
processedInput: string;
|
|
resultType: string;
|
|
timestamp: string;
|
|
};
|
|
expect(structuredContent.processedInput).toBe('hello');
|
|
expect(structuredContent.resultType).toBe('structured');
|
|
expect(structuredContent.timestamp).toBe('2023-01-01T00:00:00Z');
|
|
|
|
// For backward compatibility, content is auto-generated from structuredContent
|
|
expect(result.content).toBeDefined();
|
|
expect(result.content!).toHaveLength(1);
|
|
expect(result.content![0]).toMatchObject({ type: 'text' });
|
|
const textContent = result.content![0] as TextContent;
|
|
expect(JSON.parse(textContent.text)).toEqual(result.structuredContent);
|
|
});
|
|
|
|
/***
|
|
* Test: Tool with Output Schema Must Provide Structured Content
|
|
*/
|
|
test('should throw error when tool with outputSchema returns no structuredContent', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
// Register a tool with outputSchema that returns only content without structuredContent
|
|
mcpServer.registerTool(
|
|
'test',
|
|
{
|
|
description: 'Test tool with output schema but missing structured content',
|
|
inputSchema: {
|
|
input: z.string()
|
|
},
|
|
outputSchema: {
|
|
processedInput: z.string(),
|
|
resultType: z.string()
|
|
}
|
|
},
|
|
async ({ input }) => ({
|
|
// Only return content without structuredContent
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `Processed: ${input}`
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
// Call the tool and expect it to throw an error
|
|
const result = await client.callTool({
|
|
name: 'test',
|
|
arguments: {
|
|
input: 'hello'
|
|
}
|
|
});
|
|
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
type: 'text',
|
|
text: expect.stringContaining(
|
|
'Output validation error: Tool test has an output schema but no structured content was provided'
|
|
)
|
|
}
|
|
])
|
|
);
|
|
});
|
|
/***
|
|
* Test: Tool with Output Schema Must Provide Structured Content
|
|
*/
|
|
test('should skip outputSchema validation when isError is true', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.registerTool(
|
|
'test',
|
|
{
|
|
description: 'Test tool with output schema but missing structured content',
|
|
inputSchema: {
|
|
input: z.string()
|
|
},
|
|
outputSchema: {
|
|
processedInput: z.string(),
|
|
resultType: z.string()
|
|
}
|
|
},
|
|
async ({ input }) => ({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `Processed: ${input}`
|
|
}
|
|
],
|
|
isError: true
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
await expect(
|
|
client.callTool({
|
|
name: 'test',
|
|
arguments: {
|
|
input: 'hello'
|
|
}
|
|
})
|
|
).resolves.toStrictEqual({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `Processed: hello`
|
|
}
|
|
],
|
|
isError: true
|
|
});
|
|
});
|
|
|
|
/***
|
|
* Test: Schema Validation Failure for Invalid Structured Content
|
|
*/
|
|
test('should fail schema validation when tool returns invalid structuredContent', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
// Register a tool with outputSchema that returns invalid data
|
|
mcpServer.registerTool(
|
|
'test',
|
|
{
|
|
description: 'Test tool with invalid structured output',
|
|
inputSchema: {
|
|
input: z.string()
|
|
},
|
|
outputSchema: {
|
|
processedInput: z.string(),
|
|
resultType: z.string(),
|
|
timestamp: z.string()
|
|
}
|
|
},
|
|
async ({ input }) => ({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: JSON.stringify({
|
|
processedInput: input,
|
|
resultType: 'structured',
|
|
// Missing required 'timestamp' field
|
|
someExtraField: 'unexpected' // Extra field not in schema
|
|
})
|
|
}
|
|
],
|
|
structuredContent: {
|
|
processedInput: input,
|
|
resultType: 'structured',
|
|
// Missing required 'timestamp' field
|
|
someExtraField: 'unexpected' // Extra field not in schema
|
|
}
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
// Call the tool and expect it to throw a server-side validation error
|
|
const result = await client.callTool({
|
|
name: 'test',
|
|
arguments: {
|
|
input: 'hello'
|
|
}
|
|
});
|
|
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
type: 'text',
|
|
text: expect.stringContaining('Output validation error: Invalid structured content for tool test')
|
|
}
|
|
])
|
|
);
|
|
});
|
|
|
|
/***
|
|
* Test: Pass Session ID to Tool Callback
|
|
*/
|
|
test('should pass sessionId to tool callback via RequestHandlerExtra', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
let receivedSessionId: string | undefined;
|
|
mcpServer.tool('test-tool', async extra => {
|
|
receivedSessionId = extra.sessionId;
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'Test response'
|
|
}
|
|
]
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
// Set a test sessionId on the server transport
|
|
serverTransport.sessionId = 'test-session-123';
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
await client.request(
|
|
{
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'test-tool'
|
|
}
|
|
},
|
|
CallToolResultSchema
|
|
);
|
|
|
|
expect(receivedSessionId).toBe('test-session-123');
|
|
});
|
|
|
|
/***
|
|
* Test: Pass Request ID to Tool Callback
|
|
*/
|
|
test('should pass requestId to tool callback via RequestHandlerExtra', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
let receivedRequestId: string | number | undefined;
|
|
mcpServer.tool('request-id-test', async extra => {
|
|
receivedRequestId = extra.requestId;
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `Received request ID: ${extra.requestId}`
|
|
}
|
|
]
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'request-id-test'
|
|
}
|
|
},
|
|
CallToolResultSchema
|
|
);
|
|
|
|
expect(receivedRequestId).toBeDefined();
|
|
expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true);
|
|
expect(result.content).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
type: 'text',
|
|
text: expect.stringContaining('Received request ID:')
|
|
}
|
|
])
|
|
);
|
|
});
|
|
|
|
/***
|
|
* Test: Send Notification within Tool Call
|
|
*/
|
|
test('should provide sendNotification within tool call', async () => {
|
|
const mcpServer = new McpServer(
|
|
{
|
|
name: 'test server',
|
|
version: '1.0'
|
|
},
|
|
{ capabilities: { logging: {} } }
|
|
);
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
let receivedLogMessage: string | undefined;
|
|
const loggingMessage = 'hello here is log message 1';
|
|
|
|
client.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
|
|
receivedLogMessage = notification.params.data as string;
|
|
});
|
|
|
|
mcpServer.tool('test-tool', async ({ sendNotification }) => {
|
|
await sendNotification({
|
|
method: 'notifications/message',
|
|
params: { level: 'debug', data: loggingMessage }
|
|
});
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'Test response'
|
|
}
|
|
]
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
await client.request(
|
|
{
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'test-tool'
|
|
}
|
|
},
|
|
CallToolResultSchema
|
|
);
|
|
expect(receivedLogMessage).toBe(loggingMessage);
|
|
});
|
|
|
|
/***
|
|
* Test: Client to Server Tool Call
|
|
*/
|
|
test('should allow client to call server tools', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.tool(
|
|
'test',
|
|
'Test tool',
|
|
{
|
|
input: z.string()
|
|
},
|
|
async ({ input }) => ({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `Processed: ${input}`
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'test',
|
|
arguments: {
|
|
input: 'hello'
|
|
}
|
|
}
|
|
},
|
|
CallToolResultSchema
|
|
);
|
|
|
|
expect(result.content).toEqual([
|
|
{
|
|
type: 'text',
|
|
text: 'Processed: hello'
|
|
}
|
|
]);
|
|
});
|
|
|
|
/***
|
|
* Test: Graceful Tool Error Handling
|
|
*/
|
|
test('should handle server tool errors gracefully', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.tool('error-test', async () => {
|
|
throw new Error('Tool execution failed');
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'error-test'
|
|
}
|
|
},
|
|
CallToolResultSchema
|
|
);
|
|
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content).toEqual([
|
|
{
|
|
type: 'text',
|
|
text: 'Tool execution failed'
|
|
}
|
|
]);
|
|
});
|
|
|
|
/***
|
|
* Test: McpError for Invalid Tool Name
|
|
*/
|
|
test('should throw McpError for invalid tool name', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.tool('test-tool', async () => ({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'Test response'
|
|
}
|
|
]
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'nonexistent-tool'
|
|
}
|
|
},
|
|
CallToolResultSchema
|
|
);
|
|
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
type: 'text',
|
|
text: expect.stringContaining('Tool nonexistent-tool not found')
|
|
}
|
|
])
|
|
);
|
|
});
|
|
|
|
/***
|
|
* Test: Tool Registration with _meta field
|
|
*/
|
|
test('should register tool with _meta field and include it in list response', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
const metaData = {
|
|
author: 'test-author',
|
|
version: '1.2.3',
|
|
category: 'utility',
|
|
tags: ['test', 'example']
|
|
};
|
|
|
|
mcpServer.registerTool(
|
|
'test-with-meta',
|
|
{
|
|
description: 'A tool with _meta field',
|
|
inputSchema: { name: z.string() },
|
|
_meta: metaData
|
|
},
|
|
async ({ name }) => ({
|
|
content: [{ type: 'text', text: `Hello, ${name}!` }]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema);
|
|
|
|
expect(result.tools).toHaveLength(1);
|
|
expect(result.tools[0].name).toBe('test-with-meta');
|
|
expect(result.tools[0].description).toBe('A tool with _meta field');
|
|
expect(result.tools[0]._meta).toEqual(metaData);
|
|
});
|
|
|
|
/***
|
|
* Test: Tool Registration without _meta field should have undefined _meta
|
|
*/
|
|
test('should register tool without _meta field and have undefined _meta in response', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.registerTool(
|
|
'test-without-meta',
|
|
{
|
|
description: 'A tool without _meta field',
|
|
inputSchema: { name: z.string() }
|
|
},
|
|
async ({ name }) => ({
|
|
content: [{ type: 'text', text: `Hello, ${name}!` }]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema);
|
|
|
|
expect(result.tools).toHaveLength(1);
|
|
expect(result.tools[0].name).toBe('test-without-meta');
|
|
expect(result.tools[0]._meta).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('resource()', () => {
|
|
/***
|
|
* Test: Resource Registration with URI and Read Callback
|
|
*/
|
|
test('should register resource with uri and readCallback', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.resource('test', 'test://resource', async () => ({
|
|
contents: [
|
|
{
|
|
uri: 'test://resource',
|
|
text: 'Test content'
|
|
}
|
|
]
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'resources/list'
|
|
},
|
|
ListResourcesResultSchema
|
|
);
|
|
|
|
expect(result.resources).toHaveLength(1);
|
|
expect(result.resources[0].name).toBe('test');
|
|
expect(result.resources[0].uri).toBe('test://resource');
|
|
});
|
|
|
|
/***
|
|
* Test: Update Resource with URI
|
|
*/
|
|
test('should update resource with uri', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const notifications: Notification[] = [];
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
client.fallbackNotificationHandler = async notification => {
|
|
notifications.push(notification);
|
|
};
|
|
|
|
// Register initial resource
|
|
const resource = mcpServer.resource('test', 'test://resource', async () => ({
|
|
contents: [
|
|
{
|
|
uri: 'test://resource',
|
|
text: 'Initial content'
|
|
}
|
|
]
|
|
}));
|
|
|
|
// Update the resource
|
|
resource.update({
|
|
callback: async () => ({
|
|
contents: [
|
|
{
|
|
uri: 'test://resource',
|
|
text: 'Updated content'
|
|
}
|
|
]
|
|
})
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
|
|
|
// Read the resource and verify we get the updated content
|
|
const result = await client.request(
|
|
{
|
|
method: 'resources/read',
|
|
params: {
|
|
uri: 'test://resource'
|
|
}
|
|
},
|
|
ReadResourceResultSchema
|
|
);
|
|
|
|
expect(result.contents).toHaveLength(1);
|
|
expect(result.contents).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
text: expect.stringContaining('Updated content'),
|
|
uri: 'test://resource'
|
|
}
|
|
])
|
|
);
|
|
|
|
// Update happened before transport was connected, so no notifications should be expected
|
|
expect(notifications).toHaveLength(0);
|
|
});
|
|
|
|
/***
|
|
* Test: Update Resource Template
|
|
*/
|
|
test('should update resource template', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const notifications: Notification[] = [];
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
client.fallbackNotificationHandler = async notification => {
|
|
notifications.push(notification);
|
|
};
|
|
|
|
// Register initial resource template
|
|
const resourceTemplate = mcpServer.resource(
|
|
'test',
|
|
new ResourceTemplate('test://resource/{id}', { list: undefined }),
|
|
async uri => ({
|
|
contents: [
|
|
{
|
|
uri: uri.href,
|
|
text: 'Initial content'
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
// Update the resource template
|
|
resourceTemplate.update({
|
|
callback: async uri => ({
|
|
contents: [
|
|
{
|
|
uri: uri.href,
|
|
text: 'Updated content'
|
|
}
|
|
]
|
|
})
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
|
|
|
// Read the resource and verify we get the updated content
|
|
const result = await client.request(
|
|
{
|
|
method: 'resources/read',
|
|
params: {
|
|
uri: 'test://resource/123'
|
|
}
|
|
},
|
|
ReadResourceResultSchema
|
|
);
|
|
|
|
expect(result.contents).toHaveLength(1);
|
|
expect(result.contents).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
text: expect.stringContaining('Updated content'),
|
|
uri: 'test://resource/123'
|
|
}
|
|
])
|
|
);
|
|
|
|
// Update happened before transport was connected, so no notifications should be expected
|
|
expect(notifications).toHaveLength(0);
|
|
});
|
|
|
|
/***
|
|
* Test: Resource List Changed Notification
|
|
*/
|
|
test('should send resource list changed notification when connected', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const notifications: Notification[] = [];
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
client.fallbackNotificationHandler = async notification => {
|
|
notifications.push(notification);
|
|
};
|
|
|
|
// Register initial resource
|
|
const resource = mcpServer.resource('test', 'test://resource', async () => ({
|
|
contents: [
|
|
{
|
|
uri: 'test://resource',
|
|
text: 'Test content'
|
|
}
|
|
]
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
|
|
|
expect(notifications).toHaveLength(0);
|
|
|
|
// Now update the resource while connected
|
|
resource.update({
|
|
callback: async () => ({
|
|
contents: [
|
|
{
|
|
uri: 'test://resource',
|
|
text: 'Updated content'
|
|
}
|
|
]
|
|
})
|
|
});
|
|
|
|
// Yield event loop to let the notification fly
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]);
|
|
});
|
|
|
|
/***
|
|
* Test: Remove Resource and Send Notification
|
|
*/
|
|
test('should remove resource and send notification when connected', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const notifications: Notification[] = [];
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
client.fallbackNotificationHandler = async notification => {
|
|
notifications.push(notification);
|
|
};
|
|
|
|
// Register initial resources
|
|
const resource1 = mcpServer.resource('resource1', 'test://resource1', async () => ({
|
|
contents: [{ uri: 'test://resource1', text: 'Resource 1 content' }]
|
|
}));
|
|
|
|
mcpServer.resource('resource2', 'test://resource2', async () => ({
|
|
contents: [{ uri: 'test://resource2', text: 'Resource 2 content' }]
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
|
|
|
// Verify both resources are registered
|
|
let result = await client.request({ method: 'resources/list' }, ListResourcesResultSchema);
|
|
|
|
expect(result.resources).toHaveLength(2);
|
|
|
|
expect(notifications).toHaveLength(0);
|
|
|
|
// Remove a resource
|
|
resource1.remove();
|
|
|
|
// Yield event loop to let the notification fly
|
|
await new Promise(process.nextTick);
|
|
|
|
// Should have sent notification
|
|
expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]);
|
|
|
|
// Verify the resource was removed
|
|
result = await client.request({ method: 'resources/list' }, ListResourcesResultSchema);
|
|
|
|
expect(result.resources).toHaveLength(1);
|
|
expect(result.resources[0].uri).toBe('test://resource2');
|
|
});
|
|
|
|
/***
|
|
* Test: Remove Resource Template and Send Notification
|
|
*/
|
|
test('should remove resource template and send notification when connected', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const notifications: Notification[] = [];
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
client.fallbackNotificationHandler = async notification => {
|
|
notifications.push(notification);
|
|
};
|
|
|
|
// Register resource template
|
|
const resourceTemplate = mcpServer.resource(
|
|
'template',
|
|
new ResourceTemplate('test://resource/{id}', { list: undefined }),
|
|
async uri => ({
|
|
contents: [
|
|
{
|
|
uri: uri.href,
|
|
text: 'Template content'
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
|
|
|
// Verify template is registered
|
|
const result = await client.request({ method: 'resources/templates/list' }, ListResourceTemplatesResultSchema);
|
|
|
|
expect(result.resourceTemplates).toHaveLength(1);
|
|
expect(notifications).toHaveLength(0);
|
|
|
|
// Remove the template
|
|
resourceTemplate.remove();
|
|
|
|
// Yield event loop to let the notification fly
|
|
await new Promise(process.nextTick);
|
|
|
|
// Should have sent notification
|
|
expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]);
|
|
|
|
// Verify the template was removed
|
|
const result2 = await client.request({ method: 'resources/templates/list' }, ListResourceTemplatesResultSchema);
|
|
|
|
expect(result2.resourceTemplates).toHaveLength(0);
|
|
});
|
|
|
|
/***
|
|
* Test: Resource Registration with Metadata
|
|
*/
|
|
test('should register resource with metadata', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.resource(
|
|
'test',
|
|
'test://resource',
|
|
{
|
|
description: 'Test resource',
|
|
mimeType: 'text/plain'
|
|
},
|
|
async () => ({
|
|
contents: [
|
|
{
|
|
uri: 'test://resource',
|
|
text: 'Test content'
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'resources/list'
|
|
},
|
|
ListResourcesResultSchema
|
|
);
|
|
|
|
expect(result.resources).toHaveLength(1);
|
|
expect(result.resources[0].description).toBe('Test resource');
|
|
expect(result.resources[0].mimeType).toBe('text/plain');
|
|
});
|
|
|
|
/***
|
|
* Test: Resource Template Registration
|
|
*/
|
|
test('should register resource template', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.resource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), async () => ({
|
|
contents: [
|
|
{
|
|
uri: 'test://resource/123',
|
|
text: 'Test content'
|
|
}
|
|
]
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'resources/templates/list'
|
|
},
|
|
ListResourceTemplatesResultSchema
|
|
);
|
|
|
|
expect(result.resourceTemplates).toHaveLength(1);
|
|
expect(result.resourceTemplates[0].name).toBe('test');
|
|
expect(result.resourceTemplates[0].uriTemplate).toBe('test://resource/{id}');
|
|
});
|
|
|
|
/***
|
|
* Test: Resource Template with List Callback
|
|
*/
|
|
test('should register resource template with listCallback', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.resource(
|
|
'test',
|
|
new ResourceTemplate('test://resource/{id}', {
|
|
list: async () => ({
|
|
resources: [
|
|
{
|
|
name: 'Resource 1',
|
|
uri: 'test://resource/1'
|
|
},
|
|
{
|
|
name: 'Resource 2',
|
|
uri: 'test://resource/2'
|
|
}
|
|
]
|
|
})
|
|
}),
|
|
async uri => ({
|
|
contents: [
|
|
{
|
|
uri: uri.href,
|
|
text: 'Test content'
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'resources/list'
|
|
},
|
|
ListResourcesResultSchema
|
|
);
|
|
|
|
expect(result.resources).toHaveLength(2);
|
|
expect(result.resources[0].name).toBe('Resource 1');
|
|
expect(result.resources[0].uri).toBe('test://resource/1');
|
|
expect(result.resources[1].name).toBe('Resource 2');
|
|
expect(result.resources[1].uri).toBe('test://resource/2');
|
|
});
|
|
|
|
/***
|
|
* Test: Template Variables to Read Callback
|
|
*/
|
|
test('should pass template variables to readCallback', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.resource(
|
|
'test',
|
|
new ResourceTemplate('test://resource/{category}/{id}', {
|
|
list: undefined
|
|
}),
|
|
async (uri, { category, id }) => ({
|
|
contents: [
|
|
{
|
|
uri: uri.href,
|
|
text: `Category: ${category}, ID: ${id}`
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'resources/read',
|
|
params: {
|
|
uri: 'test://resource/books/123'
|
|
}
|
|
},
|
|
ReadResourceResultSchema
|
|
);
|
|
|
|
expect(result.contents).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
text: expect.stringContaining('Category: books, ID: 123'),
|
|
uri: 'test://resource/books/123'
|
|
}
|
|
])
|
|
);
|
|
});
|
|
|
|
/***
|
|
* Test: Preventing Duplicate Resource Registration
|
|
*/
|
|
test('should prevent duplicate resource registration', () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.resource('test', 'test://resource', async () => ({
|
|
contents: [
|
|
{
|
|
uri: 'test://resource',
|
|
text: 'Test content'
|
|
}
|
|
]
|
|
}));
|
|
|
|
expect(() => {
|
|
mcpServer.resource('test2', 'test://resource', async () => ({
|
|
contents: [
|
|
{
|
|
uri: 'test://resource',
|
|
text: 'Test content 2'
|
|
}
|
|
]
|
|
}));
|
|
}).toThrow(/already registered/);
|
|
});
|
|
|
|
/***
|
|
* Test: Multiple Resource Registration
|
|
*/
|
|
test('should allow registering multiple resources', () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
// This should succeed
|
|
mcpServer.resource('resource1', 'test://resource1', async () => ({
|
|
contents: [
|
|
{
|
|
uri: 'test://resource1',
|
|
text: 'Test content 1'
|
|
}
|
|
]
|
|
}));
|
|
|
|
// This should also succeed and not throw about request handlers
|
|
mcpServer.resource('resource2', 'test://resource2', async () => ({
|
|
contents: [
|
|
{
|
|
uri: 'test://resource2',
|
|
text: 'Test content 2'
|
|
}
|
|
]
|
|
}));
|
|
});
|
|
|
|
/***
|
|
* Test: Preventing Duplicate Resource Template Registration
|
|
*/
|
|
test('should prevent duplicate resource template registration', () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.resource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), async () => ({
|
|
contents: [
|
|
{
|
|
uri: 'test://resource/123',
|
|
text: 'Test content'
|
|
}
|
|
]
|
|
}));
|
|
|
|
expect(() => {
|
|
mcpServer.resource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), async () => ({
|
|
contents: [
|
|
{
|
|
uri: 'test://resource/123',
|
|
text: 'Test content 2'
|
|
}
|
|
]
|
|
}));
|
|
}).toThrow(/already registered/);
|
|
});
|
|
|
|
/***
|
|
* Test: Graceful Resource Read Error Handling
|
|
*/
|
|
test('should handle resource read errors gracefully', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.resource('error-test', 'test://error', async () => {
|
|
throw new Error('Resource read failed');
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
await expect(
|
|
client.request(
|
|
{
|
|
method: 'resources/read',
|
|
params: {
|
|
uri: 'test://error'
|
|
}
|
|
},
|
|
ReadResourceResultSchema
|
|
)
|
|
).rejects.toThrow(/Resource read failed/);
|
|
});
|
|
|
|
/***
|
|
* Test: McpError for Invalid Resource URI
|
|
*/
|
|
test('should throw McpError for invalid resource URI', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.resource('test', 'test://resource', async () => ({
|
|
contents: [
|
|
{
|
|
uri: 'test://resource',
|
|
text: 'Test content'
|
|
}
|
|
]
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
await expect(
|
|
client.request(
|
|
{
|
|
method: 'resources/read',
|
|
params: {
|
|
uri: 'test://nonexistent'
|
|
}
|
|
},
|
|
ReadResourceResultSchema
|
|
)
|
|
).rejects.toThrow(/Resource test:\/\/nonexistent not found/);
|
|
});
|
|
|
|
/***
|
|
* Test: Registering a resource template with a complete callback should update server capabilities to advertise support for completion
|
|
*/
|
|
test('should advertise support for completion when a resource template with a complete callback is defined', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.resource(
|
|
'test',
|
|
new ResourceTemplate('test://resource/{category}', {
|
|
list: undefined,
|
|
complete: {
|
|
category: () => ['books', 'movies', 'music']
|
|
}
|
|
}),
|
|
async () => ({
|
|
contents: [
|
|
{
|
|
uri: 'test://resource/test',
|
|
text: 'Test content'
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
expect(client.getServerCapabilities()).toMatchObject({ completions: {} });
|
|
});
|
|
|
|
/***
|
|
* Test: Resource Template Parameter Completion
|
|
*/
|
|
test('should support completion of resource template parameters', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.resource(
|
|
'test',
|
|
new ResourceTemplate('test://resource/{category}', {
|
|
list: undefined,
|
|
complete: {
|
|
category: () => ['books', 'movies', 'music']
|
|
}
|
|
}),
|
|
async () => ({
|
|
contents: [
|
|
{
|
|
uri: 'test://resource/test',
|
|
text: 'Test content'
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'completion/complete',
|
|
params: {
|
|
ref: {
|
|
type: 'ref/resource',
|
|
uri: 'test://resource/{category}'
|
|
},
|
|
argument: {
|
|
name: 'category',
|
|
value: ''
|
|
}
|
|
}
|
|
},
|
|
CompleteResultSchema
|
|
);
|
|
|
|
expect(result.completion.values).toEqual(['books', 'movies', 'music']);
|
|
expect(result.completion.total).toBe(3);
|
|
});
|
|
|
|
/***
|
|
* Test: Filtered Resource Template Parameter Completion
|
|
*/
|
|
test('should support filtered completion of resource template parameters', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.resource(
|
|
'test',
|
|
new ResourceTemplate('test://resource/{category}', {
|
|
list: undefined,
|
|
complete: {
|
|
category: (test: string) => ['books', 'movies', 'music'].filter(value => value.startsWith(test))
|
|
}
|
|
}),
|
|
async () => ({
|
|
contents: [
|
|
{
|
|
uri: 'test://resource/test',
|
|
text: 'Test content'
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'completion/complete',
|
|
params: {
|
|
ref: {
|
|
type: 'ref/resource',
|
|
uri: 'test://resource/{category}'
|
|
},
|
|
argument: {
|
|
name: 'category',
|
|
value: 'm'
|
|
}
|
|
}
|
|
},
|
|
CompleteResultSchema
|
|
);
|
|
|
|
expect(result.completion.values).toEqual(['movies', 'music']);
|
|
expect(result.completion.total).toBe(2);
|
|
});
|
|
|
|
/***
|
|
* Test: Pass Request ID to Resource Callback
|
|
*/
|
|
test('should pass requestId to resource callback via RequestHandlerExtra', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
let receivedRequestId: string | number | undefined;
|
|
mcpServer.resource('request-id-test', 'test://resource', async (_uri, extra) => {
|
|
receivedRequestId = extra.requestId;
|
|
return {
|
|
contents: [
|
|
{
|
|
uri: 'test://resource',
|
|
text: `Received request ID: ${extra.requestId}`
|
|
}
|
|
]
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'resources/read',
|
|
params: {
|
|
uri: 'test://resource'
|
|
}
|
|
},
|
|
ReadResourceResultSchema
|
|
);
|
|
|
|
expect(receivedRequestId).toBeDefined();
|
|
expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true);
|
|
expect(result.contents).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
text: expect.stringContaining(`Received request ID:`),
|
|
uri: 'test://resource'
|
|
}
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('prompt()', () => {
|
|
/***
|
|
* Test: Zero-Argument Prompt Registration
|
|
*/
|
|
test('should register zero-argument prompt', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.prompt('test', async () => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: 'Test response'
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'prompts/list'
|
|
},
|
|
ListPromptsResultSchema
|
|
);
|
|
|
|
expect(result.prompts).toHaveLength(1);
|
|
expect(result.prompts[0].name).toBe('test');
|
|
expect(result.prompts[0].arguments).toBeUndefined();
|
|
});
|
|
/***
|
|
* Test: Updating Existing Prompt
|
|
*/
|
|
test('should update existing prompt', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const notifications: Notification[] = [];
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
client.fallbackNotificationHandler = async notification => {
|
|
notifications.push(notification);
|
|
};
|
|
|
|
// Register initial prompt
|
|
const prompt = mcpServer.prompt('test', async () => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: 'Initial response'
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
// Update the prompt
|
|
prompt.update({
|
|
callback: async () => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: 'Updated response'
|
|
}
|
|
}
|
|
]
|
|
})
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
|
|
|
// Call the prompt and verify we get the updated response
|
|
const result = await client.request(
|
|
{
|
|
method: 'prompts/get',
|
|
params: {
|
|
name: 'test'
|
|
}
|
|
},
|
|
GetPromptResultSchema
|
|
);
|
|
|
|
expect(result.messages).toHaveLength(1);
|
|
expect(result.messages).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: 'Updated response'
|
|
}
|
|
}
|
|
])
|
|
);
|
|
|
|
// Update happened before transport was connected, so no notifications should be expected
|
|
expect(notifications).toHaveLength(0);
|
|
});
|
|
|
|
/***
|
|
* Test: Updating Prompt with Schema
|
|
*/
|
|
test('should update prompt with schema', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const notifications: Notification[] = [];
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
client.fallbackNotificationHandler = async notification => {
|
|
notifications.push(notification);
|
|
};
|
|
|
|
// Register initial prompt
|
|
const prompt = mcpServer.prompt(
|
|
'test',
|
|
{
|
|
name: z.string()
|
|
},
|
|
async ({ name }) => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: `Initial: ${name}`
|
|
}
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
// Update the prompt with a different schema
|
|
prompt.update({
|
|
argsSchema: {
|
|
name: z.string(),
|
|
value: z.string()
|
|
},
|
|
callback: async ({ name, value }) => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: `Updated: ${name}, ${value}`
|
|
}
|
|
}
|
|
]
|
|
})
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
|
|
|
// Verify the schema was updated
|
|
const listResult = await client.request(
|
|
{
|
|
method: 'prompts/list'
|
|
},
|
|
ListPromptsResultSchema
|
|
);
|
|
|
|
expect(listResult.prompts[0].arguments).toHaveLength(2);
|
|
expect(listResult.prompts[0].arguments?.map(a => a.name).sort()).toEqual(['name', 'value']);
|
|
|
|
// Call the prompt with the new schema
|
|
const getResult = await client.request(
|
|
{
|
|
method: 'prompts/get',
|
|
params: {
|
|
name: 'test',
|
|
arguments: {
|
|
name: 'test',
|
|
value: 'value'
|
|
}
|
|
}
|
|
},
|
|
GetPromptResultSchema
|
|
);
|
|
|
|
expect(getResult.messages).toHaveLength(1);
|
|
expect(getResult.messages).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: 'Updated: test, value'
|
|
}
|
|
}
|
|
])
|
|
);
|
|
|
|
// Update happened before transport was connected, so no notifications should be expected
|
|
expect(notifications).toHaveLength(0);
|
|
});
|
|
|
|
/***
|
|
* Test: Prompt List Changed Notification
|
|
*/
|
|
test('should send prompt list changed notification when connected', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const notifications: Notification[] = [];
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
client.fallbackNotificationHandler = async notification => {
|
|
notifications.push(notification);
|
|
};
|
|
|
|
// Register initial prompt
|
|
const prompt = mcpServer.prompt('test', async () => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: 'Test response'
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
|
|
|
expect(notifications).toHaveLength(0);
|
|
|
|
// Now update the prompt while connected
|
|
prompt.update({
|
|
callback: async () => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: 'Updated response'
|
|
}
|
|
}
|
|
]
|
|
})
|
|
});
|
|
|
|
// Yield event loop to let the notification fly
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(notifications).toMatchObject([{ method: 'notifications/prompts/list_changed' }]);
|
|
});
|
|
|
|
/***
|
|
* Test: Remove Prompt and Send Notification
|
|
*/
|
|
test('should remove prompt and send notification when connected', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const notifications: Notification[] = [];
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
client.fallbackNotificationHandler = async notification => {
|
|
notifications.push(notification);
|
|
};
|
|
|
|
// Register initial prompts
|
|
const prompt1 = mcpServer.prompt('prompt1', async () => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: 'Prompt 1 response'
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
mcpServer.prompt('prompt2', async () => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: 'Prompt 2 response'
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
|
|
|
// Verify both prompts are registered
|
|
let result = await client.request({ method: 'prompts/list' }, ListPromptsResultSchema);
|
|
|
|
expect(result.prompts).toHaveLength(2);
|
|
expect(result.prompts.map(p => p.name).sort()).toEqual(['prompt1', 'prompt2']);
|
|
|
|
expect(notifications).toHaveLength(0);
|
|
|
|
// Remove a prompt
|
|
prompt1.remove();
|
|
|
|
// Yield event loop to let the notification fly
|
|
await new Promise(process.nextTick);
|
|
|
|
// Should have sent notification
|
|
expect(notifications).toMatchObject([{ method: 'notifications/prompts/list_changed' }]);
|
|
|
|
// Verify the prompt was removed
|
|
result = await client.request({ method: 'prompts/list' }, ListPromptsResultSchema);
|
|
|
|
expect(result.prompts).toHaveLength(1);
|
|
expect(result.prompts[0].name).toBe('prompt2');
|
|
});
|
|
|
|
/***
|
|
* Test: Prompt Registration with Arguments Schema
|
|
*/
|
|
test('should register prompt with args schema', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.prompt(
|
|
'test',
|
|
{
|
|
name: z.string(),
|
|
value: z.string()
|
|
},
|
|
async ({ name, value }) => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: `${name}: ${value}`
|
|
}
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'prompts/list'
|
|
},
|
|
ListPromptsResultSchema
|
|
);
|
|
|
|
expect(result.prompts).toHaveLength(1);
|
|
expect(result.prompts[0].name).toBe('test');
|
|
expect(result.prompts[0].arguments).toEqual([
|
|
{ name: 'name', required: true },
|
|
{ name: 'value', required: true }
|
|
]);
|
|
});
|
|
|
|
/***
|
|
* Test: Prompt Registration with Description
|
|
*/
|
|
test('should register prompt with description', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.prompt('test', 'Test description', async () => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: 'Test response'
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'prompts/list'
|
|
},
|
|
ListPromptsResultSchema
|
|
);
|
|
|
|
expect(result.prompts).toHaveLength(1);
|
|
expect(result.prompts[0].name).toBe('test');
|
|
expect(result.prompts[0].description).toBe('Test description');
|
|
});
|
|
|
|
/***
|
|
* Test: Prompt Argument Validation
|
|
*/
|
|
test('should validate prompt args', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.prompt(
|
|
'test',
|
|
{
|
|
name: z.string(),
|
|
value: z.string().min(3)
|
|
},
|
|
async ({ name, value }) => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: `${name}: ${value}`
|
|
}
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
await expect(
|
|
client.request(
|
|
{
|
|
method: 'prompts/get',
|
|
params: {
|
|
name: 'test',
|
|
arguments: {
|
|
name: 'test',
|
|
value: 'ab' // Too short
|
|
}
|
|
}
|
|
},
|
|
GetPromptResultSchema
|
|
)
|
|
).rejects.toThrow(/Invalid arguments/);
|
|
});
|
|
|
|
/***
|
|
* Test: Preventing Duplicate Prompt Registration
|
|
*/
|
|
test('should prevent duplicate prompt registration', () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.prompt('test', async () => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: 'Test response'
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
expect(() => {
|
|
mcpServer.prompt('test', async () => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: 'Test response 2'
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
}).toThrow(/already registered/);
|
|
});
|
|
|
|
/***
|
|
* Test: Multiple Prompt Registration
|
|
*/
|
|
test('should allow registering multiple prompts', () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
// This should succeed
|
|
mcpServer.prompt('prompt1', async () => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: 'Test response 1'
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
// This should also succeed and not throw about request handlers
|
|
mcpServer.prompt('prompt2', async () => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: 'Test response 2'
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
});
|
|
|
|
/***
|
|
* Test: Prompt Registration with Arguments
|
|
*/
|
|
test('should allow registering prompts with arguments', () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
// This should succeed
|
|
mcpServer.prompt('echo', { message: z.string() }, ({ message }) => ({
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: {
|
|
type: 'text',
|
|
text: `Please process this message: ${message}`
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
});
|
|
|
|
/***
|
|
* Test: Resources and Prompts with Completion Handlers
|
|
*/
|
|
test('should allow registering both resources and prompts with completion handlers', () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
// Register a resource with completion
|
|
mcpServer.resource(
|
|
'test',
|
|
new ResourceTemplate('test://resource/{category}', {
|
|
list: undefined,
|
|
complete: {
|
|
category: () => ['books', 'movies', 'music']
|
|
}
|
|
}),
|
|
async () => ({
|
|
contents: [
|
|
{
|
|
uri: 'test://resource/test',
|
|
text: 'Test content'
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
// Register a prompt with completion
|
|
mcpServer.prompt('echo', { message: completable(z.string(), () => ['hello', 'world']) }, ({ message }) => ({
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: {
|
|
type: 'text',
|
|
text: `Please process this message: ${message}`
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
});
|
|
|
|
/***
|
|
* Test: McpError for Invalid Prompt Name
|
|
*/
|
|
test('should throw McpError for invalid prompt name', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.prompt('test-prompt', async () => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: 'Test response'
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
await expect(
|
|
client.request(
|
|
{
|
|
method: 'prompts/get',
|
|
params: {
|
|
name: 'nonexistent-prompt'
|
|
}
|
|
},
|
|
GetPromptResultSchema
|
|
)
|
|
).rejects.toThrow(/Prompt nonexistent-prompt not found/);
|
|
});
|
|
|
|
/***
|
|
* Test: Registering a prompt with a completable argument should update server capabilities to advertise support for completion
|
|
*/
|
|
test('should advertise support for completion when a prompt with a completable argument is defined', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.prompt(
|
|
'test-prompt',
|
|
{
|
|
name: completable(z.string(), () => ['Alice', 'Bob', 'Charlie'])
|
|
},
|
|
async ({ name }) => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: `Hello ${name}`
|
|
}
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
expect(client.getServerCapabilities()).toMatchObject({ completions: {} });
|
|
});
|
|
|
|
/***
|
|
* Test: Prompt Argument Completion
|
|
*/
|
|
test('should support completion of prompt arguments', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.prompt(
|
|
'test-prompt',
|
|
{
|
|
name: completable(z.string(), () => ['Alice', 'Bob', 'Charlie'])
|
|
},
|
|
async ({ name }) => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: `Hello ${name}`
|
|
}
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'completion/complete',
|
|
params: {
|
|
ref: {
|
|
type: 'ref/prompt',
|
|
name: 'test-prompt'
|
|
},
|
|
argument: {
|
|
name: 'name',
|
|
value: ''
|
|
}
|
|
}
|
|
},
|
|
CompleteResultSchema
|
|
);
|
|
|
|
expect(result.completion.values).toEqual(['Alice', 'Bob', 'Charlie']);
|
|
expect(result.completion.total).toBe(3);
|
|
});
|
|
|
|
/***
|
|
* Test: Filtered Prompt Argument Completion
|
|
*/
|
|
test('should support filtered completion of prompt arguments', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.prompt(
|
|
'test-prompt',
|
|
{
|
|
name: completable(z.string(), test => ['Alice', 'Bob', 'Charlie'].filter(value => value.startsWith(test)))
|
|
},
|
|
async ({ name }) => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: `Hello ${name}`
|
|
}
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'completion/complete',
|
|
params: {
|
|
ref: {
|
|
type: 'ref/prompt',
|
|
name: 'test-prompt'
|
|
},
|
|
argument: {
|
|
name: 'name',
|
|
value: 'A'
|
|
}
|
|
}
|
|
},
|
|
CompleteResultSchema
|
|
);
|
|
|
|
expect(result.completion.values).toEqual(['Alice']);
|
|
expect(result.completion.total).toBe(1);
|
|
});
|
|
|
|
/***
|
|
* Test: Pass Request ID to Prompt Callback
|
|
*/
|
|
test('should pass requestId to prompt callback via RequestHandlerExtra', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
let receivedRequestId: string | number | undefined;
|
|
mcpServer.prompt('request-id-test', async extra => {
|
|
receivedRequestId = extra.requestId;
|
|
return {
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: `Received request ID: ${extra.requestId}`
|
|
}
|
|
}
|
|
]
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'prompts/get',
|
|
params: {
|
|
name: 'request-id-test'
|
|
}
|
|
},
|
|
GetPromptResultSchema
|
|
);
|
|
|
|
expect(receivedRequestId).toBeDefined();
|
|
expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true);
|
|
expect(result.messages).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: expect.stringContaining(`Received request ID:`)
|
|
}
|
|
}
|
|
])
|
|
);
|
|
});
|
|
|
|
/***
|
|
* Test: Resource Template Metadata Priority
|
|
*/
|
|
test('should prioritize individual resource metadata over template metadata', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.resource(
|
|
'test',
|
|
new ResourceTemplate('test://resource/{id}', {
|
|
list: async () => ({
|
|
resources: [
|
|
{
|
|
name: 'Resource 1',
|
|
uri: 'test://resource/1',
|
|
description: 'Individual resource description',
|
|
mimeType: 'text/plain'
|
|
},
|
|
{
|
|
name: 'Resource 2',
|
|
uri: 'test://resource/2'
|
|
// This resource has no description or mimeType
|
|
}
|
|
]
|
|
})
|
|
}),
|
|
{
|
|
description: 'Template description',
|
|
mimeType: 'application/json'
|
|
},
|
|
async uri => ({
|
|
contents: [
|
|
{
|
|
uri: uri.href,
|
|
text: 'Test content'
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'resources/list'
|
|
},
|
|
ListResourcesResultSchema
|
|
);
|
|
|
|
expect(result.resources).toHaveLength(2);
|
|
|
|
// Resource 1 should have its own metadata
|
|
expect(result.resources[0].name).toBe('Resource 1');
|
|
expect(result.resources[0].description).toBe('Individual resource description');
|
|
expect(result.resources[0].mimeType).toBe('text/plain');
|
|
|
|
// Resource 2 should inherit template metadata
|
|
expect(result.resources[1].name).toBe('Resource 2');
|
|
expect(result.resources[1].description).toBe('Template description');
|
|
expect(result.resources[1].mimeType).toBe('application/json');
|
|
});
|
|
|
|
/***
|
|
* Test: Resource Template Metadata Overrides All Fields
|
|
*/
|
|
test('should allow resource to override all template metadata fields', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.resource(
|
|
'test',
|
|
new ResourceTemplate('test://resource/{id}', {
|
|
list: async () => ({
|
|
resources: [
|
|
{
|
|
name: 'Overridden Name',
|
|
uri: 'test://resource/1',
|
|
description: 'Overridden description',
|
|
mimeType: 'text/markdown'
|
|
// Add any other metadata fields if they exist
|
|
}
|
|
]
|
|
})
|
|
}),
|
|
{
|
|
title: 'Template Name',
|
|
description: 'Template description',
|
|
mimeType: 'application/json'
|
|
},
|
|
async uri => ({
|
|
contents: [
|
|
{
|
|
uri: uri.href,
|
|
text: 'Test content'
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
const result = await client.request(
|
|
{
|
|
method: 'resources/list'
|
|
},
|
|
ListResourcesResultSchema
|
|
);
|
|
|
|
expect(result.resources).toHaveLength(1);
|
|
|
|
// All fields should be from the individual resource, not the template
|
|
expect(result.resources[0].name).toBe('Overridden Name');
|
|
expect(result.resources[0].description).toBe('Overridden description');
|
|
expect(result.resources[0].mimeType).toBe('text/markdown');
|
|
});
|
|
});
|
|
|
|
describe('Tool title precedence', () => {
|
|
test('should follow correct title precedence: title → annotations.title → name', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
// Tool 1: Only name
|
|
mcpServer.tool('tool_name_only', async () => ({
|
|
content: [{ type: 'text', text: 'Response' }]
|
|
}));
|
|
|
|
// Tool 2: Name and annotations.title
|
|
mcpServer.tool(
|
|
'tool_with_annotations_title',
|
|
'Tool with annotations title',
|
|
{
|
|
title: 'Annotations Title'
|
|
},
|
|
async () => ({
|
|
content: [{ type: 'text', text: 'Response' }]
|
|
})
|
|
);
|
|
|
|
// Tool 3: Name and title (using registerTool)
|
|
mcpServer.registerTool(
|
|
'tool_with_title',
|
|
{
|
|
title: 'Regular Title',
|
|
description: 'Tool with regular title'
|
|
},
|
|
async () => ({
|
|
content: [{ type: 'text' as const, text: 'Response' }]
|
|
})
|
|
);
|
|
|
|
// Tool 4: All three - title should win
|
|
mcpServer.registerTool(
|
|
'tool_with_all_titles',
|
|
{
|
|
title: 'Regular Title Wins',
|
|
description: 'Tool with all titles',
|
|
annotations: {
|
|
title: 'Annotations Title Should Not Show'
|
|
}
|
|
},
|
|
async () => ({
|
|
content: [{ type: 'text' as const, text: 'Response' }]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
|
|
|
const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema);
|
|
|
|
expect(result.tools).toHaveLength(4);
|
|
|
|
// Tool 1: Only name - should display name
|
|
const tool1 = result.tools.find(t => t.name === 'tool_name_only');
|
|
expect(tool1).toBeDefined();
|
|
expect(getDisplayName(tool1!)).toBe('tool_name_only');
|
|
|
|
// Tool 2: Name and annotations.title - should display annotations.title
|
|
const tool2 = result.tools.find(t => t.name === 'tool_with_annotations_title');
|
|
expect(tool2).toBeDefined();
|
|
expect(tool2!.annotations?.title).toBe('Annotations Title');
|
|
expect(getDisplayName(tool2!)).toBe('Annotations Title');
|
|
|
|
// Tool 3: Name and title - should display title
|
|
const tool3 = result.tools.find(t => t.name === 'tool_with_title');
|
|
expect(tool3).toBeDefined();
|
|
expect(tool3!.title).toBe('Regular Title');
|
|
expect(getDisplayName(tool3!)).toBe('Regular Title');
|
|
|
|
// Tool 4: All three - title should take precedence
|
|
const tool4 = result.tools.find(t => t.name === 'tool_with_all_titles');
|
|
expect(tool4).toBeDefined();
|
|
expect(tool4!.title).toBe('Regular Title Wins');
|
|
expect(tool4!.annotations?.title).toBe('Annotations Title Should Not Show');
|
|
expect(getDisplayName(tool4!)).toBe('Regular Title Wins');
|
|
});
|
|
|
|
test('getDisplayName unit tests for title precedence', () => {
|
|
// Test 1: Only name
|
|
expect(getDisplayName({ name: 'tool_name' })).toBe('tool_name');
|
|
|
|
// Test 2: Name and title - title wins
|
|
expect(
|
|
getDisplayName({
|
|
name: 'tool_name',
|
|
title: 'Tool Title'
|
|
})
|
|
).toBe('Tool Title');
|
|
|
|
// Test 3: Name and annotations.title - annotations.title wins
|
|
expect(
|
|
getDisplayName({
|
|
name: 'tool_name',
|
|
annotations: { title: 'Annotations Title' }
|
|
})
|
|
).toBe('Annotations Title');
|
|
|
|
// Test 4: All three - title wins (correct precedence)
|
|
expect(
|
|
getDisplayName({
|
|
name: 'tool_name',
|
|
title: 'Regular Title',
|
|
annotations: { title: 'Annotations Title' }
|
|
})
|
|
).toBe('Regular Title');
|
|
|
|
// Test 5: Empty title should not be used
|
|
expect(
|
|
getDisplayName({
|
|
name: 'tool_name',
|
|
title: '',
|
|
annotations: { title: 'Annotations Title' }
|
|
})
|
|
).toBe('Annotations Title');
|
|
|
|
// Test 6: Undefined vs null handling
|
|
expect(
|
|
getDisplayName({
|
|
name: 'tool_name',
|
|
title: undefined,
|
|
annotations: { title: 'Annotations Title' }
|
|
})
|
|
).toBe('Annotations Title');
|
|
});
|
|
|
|
test('should support resource template completion with resolved context', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.registerResource(
|
|
'test',
|
|
new ResourceTemplate('github://repos/{owner}/{repo}', {
|
|
list: undefined,
|
|
complete: {
|
|
repo: (value, context) => {
|
|
if (context?.arguments?.['owner'] === 'org1') {
|
|
return ['project1', 'project2', 'project3'].filter(r => r.startsWith(value));
|
|
} else if (context?.arguments?.['owner'] === 'org2') {
|
|
return ['repo1', 'repo2', 'repo3'].filter(r => r.startsWith(value));
|
|
}
|
|
return [];
|
|
}
|
|
}
|
|
}),
|
|
{
|
|
title: 'GitHub Repository',
|
|
description: 'Repository information'
|
|
},
|
|
async () => ({
|
|
contents: [
|
|
{
|
|
uri: 'github://repos/test/test',
|
|
text: 'Test content'
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
// Test with microsoft owner
|
|
const result1 = await client.request(
|
|
{
|
|
method: 'completion/complete',
|
|
params: {
|
|
ref: {
|
|
type: 'ref/resource',
|
|
uri: 'github://repos/{owner}/{repo}'
|
|
},
|
|
argument: {
|
|
name: 'repo',
|
|
value: 'p'
|
|
},
|
|
context: {
|
|
arguments: {
|
|
owner: 'org1'
|
|
}
|
|
}
|
|
}
|
|
},
|
|
CompleteResultSchema
|
|
);
|
|
|
|
expect(result1.completion.values).toEqual(['project1', 'project2', 'project3']);
|
|
expect(result1.completion.total).toBe(3);
|
|
|
|
// Test with facebook owner
|
|
const result2 = await client.request(
|
|
{
|
|
method: 'completion/complete',
|
|
params: {
|
|
ref: {
|
|
type: 'ref/resource',
|
|
uri: 'github://repos/{owner}/{repo}'
|
|
},
|
|
argument: {
|
|
name: 'repo',
|
|
value: 'r'
|
|
},
|
|
context: {
|
|
arguments: {
|
|
owner: 'org2'
|
|
}
|
|
}
|
|
}
|
|
},
|
|
CompleteResultSchema
|
|
);
|
|
|
|
expect(result2.completion.values).toEqual(['repo1', 'repo2', 'repo3']);
|
|
expect(result2.completion.total).toBe(3);
|
|
|
|
// Test with no resolved context
|
|
const result3 = await client.request(
|
|
{
|
|
method: 'completion/complete',
|
|
params: {
|
|
ref: {
|
|
type: 'ref/resource',
|
|
uri: 'github://repos/{owner}/{repo}'
|
|
},
|
|
argument: {
|
|
name: 'repo',
|
|
value: 't'
|
|
}
|
|
}
|
|
},
|
|
CompleteResultSchema
|
|
);
|
|
|
|
expect(result3.completion.values).toEqual([]);
|
|
expect(result3.completion.total).toBe(0);
|
|
});
|
|
|
|
test('should support prompt argument completion with resolved context', async () => {
|
|
const mcpServer = new McpServer({
|
|
name: 'test server',
|
|
version: '1.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test client',
|
|
version: '1.0'
|
|
});
|
|
|
|
mcpServer.registerPrompt(
|
|
'test-prompt',
|
|
{
|
|
title: 'Team Greeting',
|
|
description: 'Generate a greeting for team members',
|
|
argsSchema: {
|
|
department: completable(z.string(), value => {
|
|
return ['engineering', 'sales', 'marketing', 'support'].filter(d => d.startsWith(value));
|
|
}),
|
|
name: completable(z.string(), (value, context) => {
|
|
const department = context?.arguments?.['department'];
|
|
if (department === 'engineering') {
|
|
return ['Alice', 'Bob', 'Charlie'].filter(n => n.startsWith(value));
|
|
} else if (department === 'sales') {
|
|
return ['David', 'Eve', 'Frank'].filter(n => n.startsWith(value));
|
|
} else if (department === 'marketing') {
|
|
return ['Grace', 'Henry', 'Iris'].filter(n => n.startsWith(value));
|
|
}
|
|
return ['Guest'].filter(n => n.startsWith(value));
|
|
})
|
|
}
|
|
},
|
|
async ({ department, name }) => ({
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: `Hello ${name}, welcome to the ${department} team!`
|
|
}
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
// Test with engineering department
|
|
const result1 = await client.request(
|
|
{
|
|
method: 'completion/complete',
|
|
params: {
|
|
ref: {
|
|
type: 'ref/prompt',
|
|
name: 'test-prompt'
|
|
},
|
|
argument: {
|
|
name: 'name',
|
|
value: 'A'
|
|
},
|
|
context: {
|
|
arguments: {
|
|
department: 'engineering'
|
|
}
|
|
}
|
|
}
|
|
},
|
|
CompleteResultSchema
|
|
);
|
|
|
|
expect(result1.completion.values).toEqual(['Alice']);
|
|
|
|
// Test with sales department
|
|
const result2 = await client.request(
|
|
{
|
|
method: 'completion/complete',
|
|
params: {
|
|
ref: {
|
|
type: 'ref/prompt',
|
|
name: 'test-prompt'
|
|
},
|
|
argument: {
|
|
name: 'name',
|
|
value: 'D'
|
|
},
|
|
context: {
|
|
arguments: {
|
|
department: 'sales'
|
|
}
|
|
}
|
|
}
|
|
},
|
|
CompleteResultSchema
|
|
);
|
|
|
|
expect(result2.completion.values).toEqual(['David']);
|
|
|
|
// Test with marketing department
|
|
const result3 = await client.request(
|
|
{
|
|
method: 'completion/complete',
|
|
params: {
|
|
ref: {
|
|
type: 'ref/prompt',
|
|
name: 'test-prompt'
|
|
},
|
|
argument: {
|
|
name: 'name',
|
|
value: 'G'
|
|
},
|
|
context: {
|
|
arguments: {
|
|
department: 'marketing'
|
|
}
|
|
}
|
|
}
|
|
},
|
|
CompleteResultSchema
|
|
);
|
|
|
|
expect(result3.completion.values).toEqual(['Grace']);
|
|
|
|
// Test with no resolved context
|
|
const result4 = await client.request(
|
|
{
|
|
method: 'completion/complete',
|
|
params: {
|
|
ref: {
|
|
type: 'ref/prompt',
|
|
name: 'test-prompt'
|
|
},
|
|
argument: {
|
|
name: 'name',
|
|
value: 'G'
|
|
}
|
|
}
|
|
},
|
|
CompleteResultSchema
|
|
);
|
|
|
|
expect(result4.completion.values).toEqual(['Guest']);
|
|
});
|
|
});
|
|
|
|
describe('elicitInput()', () => {
|
|
const checkAvailability = jest.fn().mockResolvedValue(false);
|
|
const findAlternatives = jest.fn().mockResolvedValue([]);
|
|
const makeBooking = jest.fn().mockResolvedValue('BOOKING-123');
|
|
|
|
let mcpServer: McpServer;
|
|
let client: Client;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
// Create server with restaurant booking tool
|
|
mcpServer = new McpServer({
|
|
name: 'restaurant-booking-server',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
// Register the restaurant booking tool from README example
|
|
mcpServer.tool(
|
|
'book-restaurant',
|
|
{
|
|
restaurant: z.string(),
|
|
date: z.string(),
|
|
partySize: z.number()
|
|
},
|
|
async ({ restaurant, date, partySize }) => {
|
|
// Check availability
|
|
const available = await checkAvailability(restaurant, date, partySize);
|
|
|
|
if (!available) {
|
|
// Ask user if they want to try alternative dates
|
|
const result = await mcpServer.server.elicitInput({
|
|
message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`,
|
|
requestedSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
checkAlternatives: {
|
|
type: 'boolean',
|
|
title: 'Check alternative dates',
|
|
description: 'Would you like me to check other dates?'
|
|
},
|
|
flexibleDates: {
|
|
type: 'string',
|
|
title: 'Date flexibility',
|
|
description: 'How flexible are your dates?',
|
|
enum: ['next_day', 'same_week', 'next_week'],
|
|
enumNames: ['Next day', 'Same week', 'Next week']
|
|
}
|
|
},
|
|
required: ['checkAlternatives']
|
|
}
|
|
});
|
|
|
|
if (result.action === 'accept' && result.content?.checkAlternatives) {
|
|
const alternatives = await findAlternatives(restaurant, date, partySize, result.content.flexibleDates as string);
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `Found these alternatives: ${alternatives.join(', ')}`
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'No booking made. Original date not available.'
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
await makeBooking(restaurant, date, partySize);
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `Booked table for ${partySize} at ${restaurant} on ${date}`
|
|
}
|
|
]
|
|
};
|
|
}
|
|
);
|
|
|
|
// Create client with elicitation capability
|
|
client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
elicitation: {}
|
|
}
|
|
}
|
|
);
|
|
});
|
|
|
|
test('should successfully elicit additional information', async () => {
|
|
// Mock availability check to return false
|
|
checkAvailability.mockResolvedValue(false);
|
|
findAlternatives.mockResolvedValue(['2024-12-26', '2024-12-27', '2024-12-28']);
|
|
|
|
// Set up client to accept alternative date checking
|
|
client.setRequestHandler(ElicitRequestSchema, async request => {
|
|
expect(request.params.message).toContain('No tables available at ABC Restaurant on 2024-12-25');
|
|
return {
|
|
action: 'accept',
|
|
content: {
|
|
checkAlternatives: true,
|
|
flexibleDates: 'same_week'
|
|
}
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
// Call the tool
|
|
const result = await client.callTool({
|
|
name: 'book-restaurant',
|
|
arguments: {
|
|
restaurant: 'ABC Restaurant',
|
|
date: '2024-12-25',
|
|
partySize: 2
|
|
}
|
|
});
|
|
|
|
expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2);
|
|
expect(findAlternatives).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2, 'same_week');
|
|
expect(result.content).toEqual([
|
|
{
|
|
type: 'text',
|
|
text: 'Found these alternatives: 2024-12-26, 2024-12-27, 2024-12-28'
|
|
}
|
|
]);
|
|
});
|
|
|
|
test('should handle user declining to elicitation request', async () => {
|
|
// Mock availability check to return false
|
|
checkAvailability.mockResolvedValue(false);
|
|
|
|
// Set up client to reject alternative date checking
|
|
client.setRequestHandler(ElicitRequestSchema, async () => {
|
|
return {
|
|
action: 'accept',
|
|
content: {
|
|
checkAlternatives: false
|
|
}
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
// Call the tool
|
|
const result = await client.callTool({
|
|
name: 'book-restaurant',
|
|
arguments: {
|
|
restaurant: 'ABC Restaurant',
|
|
date: '2024-12-25',
|
|
partySize: 2
|
|
}
|
|
});
|
|
|
|
expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2);
|
|
expect(findAlternatives).not.toHaveBeenCalled();
|
|
expect(result.content).toEqual([
|
|
{
|
|
type: 'text',
|
|
text: 'No booking made. Original date not available.'
|
|
}
|
|
]);
|
|
});
|
|
|
|
test('should handle user cancelling the elicitation', async () => {
|
|
// Mock availability check to return false
|
|
checkAvailability.mockResolvedValue(false);
|
|
|
|
// Set up client to cancel the elicitation
|
|
client.setRequestHandler(ElicitRequestSchema, async () => {
|
|
return {
|
|
action: 'cancel'
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
|
|
|
// Call the tool
|
|
const result = await client.callTool({
|
|
name: 'book-restaurant',
|
|
arguments: {
|
|
restaurant: 'ABC Restaurant',
|
|
date: '2024-12-25',
|
|
partySize: 2
|
|
}
|
|
});
|
|
|
|
expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2);
|
|
expect(findAlternatives).not.toHaveBeenCalled();
|
|
expect(result.content).toEqual([
|
|
{
|
|
type: 'text',
|
|
text: 'No booking made. Original date not available.'
|
|
}
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('Tools with union and intersection schemas', () => {
|
|
test('should support union schemas', async () => {
|
|
const server = new McpServer({
|
|
name: 'test',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
const unionSchema = z.union([
|
|
z.object({ type: z.literal('email'), email: z.string().email() }),
|
|
z.object({ type: z.literal('phone'), phone: z.string() })
|
|
]);
|
|
|
|
server.registerTool('contact', { inputSchema: unionSchema }, async args => {
|
|
if (args.type === 'email') {
|
|
return {
|
|
content: [{ type: 'text', text: `Email contact: ${args.email}` }]
|
|
};
|
|
} else {
|
|
return {
|
|
content: [{ type: 'text', text: `Phone contact: ${args.phone}` }]
|
|
};
|
|
}
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
|
|
const emailResult = await client.callTool({
|
|
name: 'contact',
|
|
arguments: {
|
|
type: 'email',
|
|
email: 'test@example.com'
|
|
}
|
|
});
|
|
|
|
expect(emailResult.content).toEqual([
|
|
{
|
|
type: 'text',
|
|
text: 'Email contact: test@example.com'
|
|
}
|
|
]);
|
|
|
|
const phoneResult = await client.callTool({
|
|
name: 'contact',
|
|
arguments: {
|
|
type: 'phone',
|
|
phone: '+1234567890'
|
|
}
|
|
});
|
|
|
|
expect(phoneResult.content).toEqual([
|
|
{
|
|
type: 'text',
|
|
text: 'Phone contact: +1234567890'
|
|
}
|
|
]);
|
|
});
|
|
|
|
test('should support intersection schemas', async () => {
|
|
const server = new McpServer({
|
|
name: 'test',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
const baseSchema = z.object({ id: z.string() });
|
|
const extendedSchema = z.object({ name: z.string(), age: z.number() });
|
|
const intersectionSchema = z.intersection(baseSchema, extendedSchema);
|
|
|
|
server.registerTool('user', { inputSchema: intersectionSchema }, async args => {
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `User: ${args.id}, ${args.name}, ${args.age} years old`
|
|
}
|
|
]
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
|
|
const result = await client.callTool({
|
|
name: 'user',
|
|
arguments: {
|
|
id: '123',
|
|
name: 'John Doe',
|
|
age: 30
|
|
}
|
|
});
|
|
|
|
expect(result.content).toEqual([
|
|
{
|
|
type: 'text',
|
|
text: 'User: 123, John Doe, 30 years old'
|
|
}
|
|
]);
|
|
});
|
|
|
|
test('should support complex nested schemas', async () => {
|
|
const server = new McpServer({
|
|
name: 'test',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
const schema = z.object({
|
|
items: z.array(
|
|
z.union([
|
|
z.object({ type: z.literal('text'), content: z.string() }),
|
|
z.object({ type: z.literal('number'), value: z.number() })
|
|
])
|
|
)
|
|
});
|
|
|
|
server.registerTool('process', { inputSchema: schema }, async args => {
|
|
const processed = args.items.map(item => {
|
|
if (item.type === 'text') {
|
|
return item.content.toUpperCase();
|
|
} else {
|
|
return item.value * 2;
|
|
}
|
|
});
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `Processed: ${processed.join(', ')}`
|
|
}
|
|
]
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
|
|
const result = await client.callTool({
|
|
name: 'process',
|
|
arguments: {
|
|
items: [
|
|
{ type: 'text', content: 'hello' },
|
|
{ type: 'number', value: 5 },
|
|
{ type: 'text', content: 'world' }
|
|
]
|
|
}
|
|
});
|
|
|
|
expect(result.content).toEqual([
|
|
{
|
|
type: 'text',
|
|
text: 'Processed: HELLO, 10, WORLD'
|
|
}
|
|
]);
|
|
});
|
|
|
|
test('should validate union schema inputs correctly', async () => {
|
|
const server = new McpServer({
|
|
name: 'test',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
const unionSchema = z.union([
|
|
z.object({ type: z.literal('a'), value: z.string() }),
|
|
z.object({ type: z.literal('b'), value: z.number() })
|
|
]);
|
|
|
|
server.registerTool('union-test', { inputSchema: unionSchema }, async () => {
|
|
return {
|
|
content: [{ type: 'text', text: 'Success' }]
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
|
|
const invalidTypeResult = await client.callTool({
|
|
name: 'union-test',
|
|
arguments: {
|
|
type: 'a',
|
|
value: 123
|
|
}
|
|
});
|
|
|
|
expect(invalidTypeResult.isError).toBe(true);
|
|
expect(invalidTypeResult.content).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
type: 'text',
|
|
text: expect.stringContaining('Input validation error')
|
|
})
|
|
])
|
|
);
|
|
|
|
const invalidDiscriminatorResult = await client.callTool({
|
|
name: 'union-test',
|
|
arguments: {
|
|
type: 'c',
|
|
value: 'test'
|
|
}
|
|
});
|
|
|
|
expect(invalidDiscriminatorResult.isError).toBe(true);
|
|
expect(invalidDiscriminatorResult.content).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
type: 'text',
|
|
text: expect.stringContaining('Input validation error')
|
|
})
|
|
])
|
|
);
|
|
});
|
|
});
|