4265 lines
134 KiB
TypeScript
4265 lines
134 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
/* eslint-disable no-constant-binary-expression */
|
|
/* eslint-disable @typescript-eslint/no-unused-expressions */
|
|
import { Client, getSupportedElicitationModes } from '../../src/client/index.js';
|
|
import {
|
|
RequestSchema,
|
|
NotificationSchema,
|
|
ResultSchema,
|
|
LATEST_PROTOCOL_VERSION,
|
|
SUPPORTED_PROTOCOL_VERSIONS,
|
|
InitializeRequestSchema,
|
|
ListResourcesRequestSchema,
|
|
ListToolsRequestSchema,
|
|
ListToolsResultSchema,
|
|
ListPromptsRequestSchema,
|
|
CallToolRequestSchema,
|
|
CallToolResultSchema,
|
|
CreateMessageRequestSchema,
|
|
ElicitRequestSchema,
|
|
ElicitResultSchema,
|
|
ListRootsRequestSchema,
|
|
ErrorCode,
|
|
McpError,
|
|
CreateTaskResultSchema,
|
|
Tool,
|
|
Prompt,
|
|
Resource
|
|
} from '../../src/types.js';
|
|
import { Transport } from '../../src/shared/transport.js';
|
|
import { Server } from '../../src/server/index.js';
|
|
import { McpServer } from '../../src/server/mcp.js';
|
|
import { InMemoryTransport } from '../../src/inMemory.js';
|
|
import { InMemoryTaskStore } from '../../src/experimental/tasks/stores/in-memory.js';
|
|
import * as z3 from 'zod/v3';
|
|
import * as z4 from 'zod/v4';
|
|
|
|
describe('Zod v4', () => {
|
|
/***
|
|
* Test: Type Checking
|
|
* Test that custom request/notification/result schemas can be used with the Client class.
|
|
*/
|
|
test('should typecheck', () => {
|
|
const GetWeatherRequestSchema = RequestSchema.extend({
|
|
method: z4.literal('weather/get'),
|
|
params: z4.object({
|
|
city: z4.string()
|
|
})
|
|
});
|
|
|
|
const GetForecastRequestSchema = RequestSchema.extend({
|
|
method: z4.literal('weather/forecast'),
|
|
params: z4.object({
|
|
city: z4.string(),
|
|
days: z4.number()
|
|
})
|
|
});
|
|
|
|
const WeatherForecastNotificationSchema = NotificationSchema.extend({
|
|
method: z4.literal('weather/alert'),
|
|
params: z4.object({
|
|
severity: z4.enum(['warning', 'watch']),
|
|
message: z4.string()
|
|
})
|
|
});
|
|
|
|
const WeatherRequestSchema = GetWeatherRequestSchema.or(GetForecastRequestSchema);
|
|
const WeatherNotificationSchema = WeatherForecastNotificationSchema;
|
|
const WeatherResultSchema = ResultSchema.extend({
|
|
temperature: z4.number(),
|
|
conditions: z4.string()
|
|
});
|
|
|
|
type WeatherRequest = z4.infer<typeof WeatherRequestSchema>;
|
|
type WeatherNotification = z4.infer<typeof WeatherNotificationSchema>;
|
|
type WeatherResult = z4.infer<typeof WeatherResultSchema>;
|
|
|
|
// Create a typed Client for weather data
|
|
const weatherClient = new Client<WeatherRequest, WeatherNotification, WeatherResult>(
|
|
{
|
|
name: 'WeatherClient',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
sampling: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
// Typecheck that only valid weather requests/notifications/results are allowed
|
|
false &&
|
|
weatherClient.request(
|
|
{
|
|
method: 'weather/get',
|
|
params: {
|
|
city: 'Seattle'
|
|
}
|
|
},
|
|
WeatherResultSchema
|
|
);
|
|
|
|
false &&
|
|
weatherClient.notification({
|
|
method: 'weather/alert',
|
|
params: {
|
|
severity: 'warning',
|
|
message: 'Storm approaching'
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Zod v3', () => {
|
|
/***
|
|
* Test: Type Checking
|
|
* Test that custom request/notification/result schemas can be used with the Client class.
|
|
*/
|
|
test('should typecheck', () => {
|
|
const GetWeatherRequestSchema = z3.object({
|
|
...RequestSchema.shape,
|
|
method: z3.literal('weather/get'),
|
|
params: z3.object({
|
|
city: z3.string()
|
|
})
|
|
});
|
|
|
|
const GetForecastRequestSchema = z3.object({
|
|
...RequestSchema.shape,
|
|
method: z3.literal('weather/forecast'),
|
|
params: z3.object({
|
|
city: z3.string(),
|
|
days: z3.number()
|
|
})
|
|
});
|
|
|
|
const WeatherForecastNotificationSchema = z3.object({
|
|
...NotificationSchema.shape,
|
|
method: z3.literal('weather/alert'),
|
|
params: z3.object({
|
|
severity: z3.enum(['warning', 'watch']),
|
|
message: z3.string()
|
|
})
|
|
});
|
|
|
|
const WeatherRequestSchema = GetWeatherRequestSchema.or(GetForecastRequestSchema);
|
|
const WeatherNotificationSchema = WeatherForecastNotificationSchema;
|
|
const WeatherResultSchema = z3.object({
|
|
...ResultSchema.shape,
|
|
_meta: z3.record(z3.string(), z3.unknown()).optional(),
|
|
temperature: z3.number(),
|
|
conditions: z3.string()
|
|
});
|
|
|
|
type WeatherRequest = z3.infer<typeof WeatherRequestSchema>;
|
|
type WeatherNotification = z3.infer<typeof WeatherNotificationSchema>;
|
|
type WeatherResult = z3.infer<typeof WeatherResultSchema>;
|
|
|
|
// Create a typed Client for weather data
|
|
const weatherClient = new Client<WeatherRequest, WeatherNotification, WeatherResult>(
|
|
{
|
|
name: 'WeatherClient',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
sampling: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
// Typecheck that only valid weather requests/notifications/results are allowed
|
|
false &&
|
|
weatherClient.request(
|
|
{
|
|
method: 'weather/get',
|
|
params: {
|
|
city: 'Seattle'
|
|
}
|
|
},
|
|
WeatherResultSchema
|
|
);
|
|
|
|
false &&
|
|
weatherClient.notification({
|
|
method: 'weather/alert',
|
|
params: {
|
|
severity: 'warning',
|
|
message: 'Storm approaching'
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
/***
|
|
* Test: Initialize with Matching Protocol Version
|
|
*/
|
|
test('should initialize with matching protocol version', async () => {
|
|
const clientTransport: Transport = {
|
|
start: vi.fn().mockResolvedValue(undefined),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
send: vi.fn().mockImplementation(message => {
|
|
if (message.method === 'initialize') {
|
|
clientTransport.onmessage?.({
|
|
jsonrpc: '2.0',
|
|
id: message.id,
|
|
result: {
|
|
protocolVersion: LATEST_PROTOCOL_VERSION,
|
|
capabilities: {},
|
|
serverInfo: {
|
|
name: 'test',
|
|
version: '1.0'
|
|
},
|
|
instructions: 'test instructions'
|
|
}
|
|
});
|
|
}
|
|
return Promise.resolve();
|
|
})
|
|
};
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test client',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
sampling: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
await client.connect(clientTransport);
|
|
|
|
// Should have sent initialize with latest version
|
|
expect(clientTransport.send).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
method: 'initialize',
|
|
params: expect.objectContaining({
|
|
protocolVersion: LATEST_PROTOCOL_VERSION
|
|
})
|
|
}),
|
|
expect.objectContaining({
|
|
relatedRequestId: undefined
|
|
})
|
|
);
|
|
|
|
// Should have the instructions returned
|
|
expect(client.getInstructions()).toEqual('test instructions');
|
|
});
|
|
|
|
/***
|
|
* Test: Initialize with Supported Older Protocol Version
|
|
*/
|
|
test('should initialize with supported older protocol version', async () => {
|
|
const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1];
|
|
const clientTransport: Transport = {
|
|
start: vi.fn().mockResolvedValue(undefined),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
send: vi.fn().mockImplementation(message => {
|
|
if (message.method === 'initialize') {
|
|
clientTransport.onmessage?.({
|
|
jsonrpc: '2.0',
|
|
id: message.id,
|
|
result: {
|
|
protocolVersion: OLD_VERSION,
|
|
capabilities: {},
|
|
serverInfo: {
|
|
name: 'test',
|
|
version: '1.0'
|
|
}
|
|
}
|
|
});
|
|
}
|
|
return Promise.resolve();
|
|
})
|
|
};
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test client',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
sampling: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
await client.connect(clientTransport);
|
|
|
|
// Connection should succeed with the older version
|
|
expect(client.getServerVersion()).toEqual({
|
|
name: 'test',
|
|
version: '1.0'
|
|
});
|
|
|
|
// Expect no instructions
|
|
expect(client.getInstructions()).toBeUndefined();
|
|
});
|
|
|
|
/***
|
|
* Test: Reject Unsupported Protocol Version
|
|
*/
|
|
test('should reject unsupported protocol version', async () => {
|
|
const clientTransport: Transport = {
|
|
start: vi.fn().mockResolvedValue(undefined),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
send: vi.fn().mockImplementation(message => {
|
|
if (message.method === 'initialize') {
|
|
clientTransport.onmessage?.({
|
|
jsonrpc: '2.0',
|
|
id: message.id,
|
|
result: {
|
|
protocolVersion: 'invalid-version',
|
|
capabilities: {},
|
|
serverInfo: {
|
|
name: 'test',
|
|
version: '1.0'
|
|
}
|
|
}
|
|
});
|
|
}
|
|
return Promise.resolve();
|
|
})
|
|
};
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test client',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
sampling: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
await expect(client.connect(clientTransport)).rejects.toThrow("Server's protocol version is not supported: invalid-version");
|
|
|
|
expect(clientTransport.close).toHaveBeenCalled();
|
|
});
|
|
|
|
/***
|
|
* Test: Connect New Client to Old Supported Server Version
|
|
*/
|
|
test('should connect new client to old, supported server version', async () => {
|
|
const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1];
|
|
const server = new Server(
|
|
{
|
|
name: 'test server',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
resources: {},
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
server.setRequestHandler(InitializeRequestSchema, _request => ({
|
|
protocolVersion: OLD_VERSION,
|
|
capabilities: {
|
|
resources: {},
|
|
tools: {}
|
|
},
|
|
serverInfo: {
|
|
name: 'old server',
|
|
version: '1.0'
|
|
}
|
|
}));
|
|
|
|
server.setRequestHandler(ListResourcesRequestSchema, () => ({
|
|
resources: []
|
|
}));
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
tools: []
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'new client',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
sampling: {}
|
|
},
|
|
enforceStrictCapabilities: true
|
|
}
|
|
);
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
expect(client.getServerVersion()).toEqual({
|
|
name: 'old server',
|
|
version: '1.0'
|
|
});
|
|
});
|
|
|
|
/***
|
|
* Test: Version Negotiation with Old Client and Newer Server
|
|
*/
|
|
test('should negotiate version when client is old, and newer server supports its version', async () => {
|
|
const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1];
|
|
const server = new Server(
|
|
{
|
|
name: 'new server',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
resources: {},
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
server.setRequestHandler(InitializeRequestSchema, _request => ({
|
|
protocolVersion: LATEST_PROTOCOL_VERSION,
|
|
capabilities: {
|
|
resources: {},
|
|
tools: {}
|
|
},
|
|
serverInfo: {
|
|
name: 'new server',
|
|
version: '1.0'
|
|
}
|
|
}));
|
|
|
|
server.setRequestHandler(ListResourcesRequestSchema, () => ({
|
|
resources: []
|
|
}));
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
tools: []
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'old client',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
sampling: {}
|
|
},
|
|
enforceStrictCapabilities: true
|
|
}
|
|
);
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
expect(client.getServerVersion()).toEqual({
|
|
name: 'new server',
|
|
version: '1.0'
|
|
});
|
|
});
|
|
|
|
/***
|
|
* Test: Throw when Old Client and Server Version Mismatch
|
|
*/
|
|
test("should throw when client is old, and server doesn't support its version", async () => {
|
|
const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1];
|
|
const FUTURE_VERSION = 'FUTURE_VERSION';
|
|
const server = new Server(
|
|
{
|
|
name: 'new server',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
resources: {},
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
server.setRequestHandler(InitializeRequestSchema, _request => ({
|
|
protocolVersion: FUTURE_VERSION,
|
|
capabilities: {
|
|
resources: {},
|
|
tools: {}
|
|
},
|
|
serverInfo: {
|
|
name: 'new server',
|
|
version: '1.0'
|
|
}
|
|
}));
|
|
|
|
server.setRequestHandler(ListResourcesRequestSchema, () => ({
|
|
resources: []
|
|
}));
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
tools: []
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'old client',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
sampling: {}
|
|
},
|
|
enforceStrictCapabilities: true
|
|
}
|
|
);
|
|
|
|
await Promise.all([
|
|
expect(client.connect(clientTransport)).rejects.toThrow("Server's protocol version is not supported: FUTURE_VERSION"),
|
|
server.connect(serverTransport)
|
|
]);
|
|
});
|
|
|
|
/***
|
|
* Test: Respect Server Capabilities
|
|
*/
|
|
test('should respect server capabilities', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test server',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
resources: {},
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
server.setRequestHandler(InitializeRequestSchema, _request => ({
|
|
protocolVersion: LATEST_PROTOCOL_VERSION,
|
|
capabilities: {
|
|
resources: {},
|
|
tools: {}
|
|
},
|
|
serverInfo: {
|
|
name: 'test',
|
|
version: '1.0'
|
|
}
|
|
}));
|
|
|
|
server.setRequestHandler(ListResourcesRequestSchema, () => ({
|
|
resources: []
|
|
}));
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
tools: []
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test client',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
sampling: {}
|
|
},
|
|
enforceStrictCapabilities: true
|
|
}
|
|
);
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Server supports resources and tools, but not prompts
|
|
expect(client.getServerCapabilities()).toEqual({
|
|
resources: {},
|
|
tools: {}
|
|
});
|
|
|
|
// These should work
|
|
await expect(client.listResources()).resolves.not.toThrow();
|
|
await expect(client.listTools()).resolves.not.toThrow();
|
|
|
|
// These should throw because prompts, logging, and completions are not supported
|
|
await expect(client.listPrompts()).rejects.toThrow('Server does not support prompts');
|
|
await expect(client.setLoggingLevel('error')).rejects.toThrow('Server does not support logging');
|
|
await expect(
|
|
client.complete({
|
|
ref: { type: 'ref/prompt', name: 'test' },
|
|
argument: { name: 'test', value: 'test' }
|
|
})
|
|
).rejects.toThrow('Server does not support completions');
|
|
});
|
|
|
|
/***
|
|
* Test: Respect Client Notification Capabilities
|
|
*/
|
|
test('should respect client notification capabilities', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test server',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {}
|
|
}
|
|
);
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test client',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
roots: {
|
|
listChanged: true
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// This should work because the client has the roots.listChanged capability
|
|
await expect(client.sendRootsListChanged()).resolves.not.toThrow();
|
|
|
|
// Create a new client without the roots.listChanged capability
|
|
const clientWithoutCapability = new Client(
|
|
{
|
|
name: 'test client without capability',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {},
|
|
enforceStrictCapabilities: true
|
|
}
|
|
);
|
|
|
|
await clientWithoutCapability.connect(clientTransport);
|
|
|
|
// This should throw because the client doesn't have the roots.listChanged capability
|
|
await expect(clientWithoutCapability.sendRootsListChanged()).rejects.toThrow(/^Client does not support/);
|
|
});
|
|
|
|
/***
|
|
* Test: Respect Server Notification Capabilities
|
|
*/
|
|
test('should respect server notification capabilities', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test server',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
logging: {},
|
|
resources: {
|
|
listChanged: true
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test client',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// These should work because the server has the corresponding capabilities
|
|
await expect(server.sendLoggingMessage({ level: 'info', data: 'Test' })).resolves.not.toThrow();
|
|
await expect(server.sendResourceListChanged()).resolves.not.toThrow();
|
|
|
|
// This should throw because the server doesn't have the tools capability
|
|
await expect(server.sendToolListChanged()).rejects.toThrow('Server does not support notifying of tool list changes');
|
|
});
|
|
|
|
/***
|
|
* Test: Only Allow setRequestHandler for Declared Capabilities
|
|
*/
|
|
test('should only allow setRequestHandler for declared capabilities', () => {
|
|
const client = new Client(
|
|
{
|
|
name: 'test client',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
sampling: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
// This should work because sampling is a declared capability
|
|
expect(() => {
|
|
client.setRequestHandler(CreateMessageRequestSchema, () => ({
|
|
model: 'test-model',
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: 'Test response'
|
|
}
|
|
}));
|
|
}).not.toThrow();
|
|
|
|
// This should throw because roots listing is not a declared capability
|
|
expect(() => {
|
|
client.setRequestHandler(ListRootsRequestSchema, () => ({}));
|
|
}).toThrow('Client does not support roots capability');
|
|
});
|
|
|
|
test('should allow setRequestHandler for declared elicitation capability', () => {
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
elicitation: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
// This should work because elicitation is a declared capability
|
|
expect(() => {
|
|
client.setRequestHandler(ElicitRequestSchema, () => ({
|
|
action: 'accept',
|
|
content: {
|
|
username: 'test-user',
|
|
confirmed: true
|
|
}
|
|
}));
|
|
}).not.toThrow();
|
|
|
|
// This should throw because sampling is not a declared capability
|
|
expect(() => {
|
|
client.setRequestHandler(CreateMessageRequestSchema, () => ({
|
|
model: 'test-model',
|
|
role: 'assistant',
|
|
content: {
|
|
type: 'text',
|
|
text: 'Test response'
|
|
}
|
|
}));
|
|
}).toThrow('Client does not support sampling capability');
|
|
});
|
|
|
|
test('should accept form-mode elicitation request when client advertises empty elicitation object (back-compat)', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test server',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
prompts: {},
|
|
resources: {},
|
|
tools: {},
|
|
logging: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test client',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
elicitation: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
// Set up client handler for form-mode elicitation
|
|
client.setRequestHandler(ElicitRequestSchema, request => {
|
|
expect(request.params.mode).toBe('form');
|
|
return {
|
|
action: 'accept',
|
|
content: {
|
|
username: 'test-user',
|
|
confirmed: true
|
|
}
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Server should be able to send form-mode elicitation request
|
|
// This works because getSupportedElicitationModes defaults to form mode
|
|
// when neither form nor url are explicitly declared
|
|
const result = await server.elicitInput({
|
|
mode: 'form',
|
|
message: 'Please provide your username',
|
|
requestedSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
username: {
|
|
type: 'string',
|
|
title: 'Username',
|
|
description: 'Your username'
|
|
},
|
|
confirmed: {
|
|
type: 'boolean',
|
|
title: 'Confirm',
|
|
description: 'Please confirm',
|
|
default: false
|
|
}
|
|
},
|
|
required: ['username']
|
|
}
|
|
});
|
|
|
|
expect(result.action).toBe('accept');
|
|
expect(result.content).toEqual({
|
|
username: 'test-user',
|
|
confirmed: true
|
|
});
|
|
});
|
|
|
|
test('should reject form-mode elicitation when client only supports URL mode', async () => {
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
elicitation: {
|
|
url: {}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const handler = vi.fn().mockResolvedValue({
|
|
action: 'cancel'
|
|
});
|
|
client.setRequestHandler(ElicitRequestSchema, handler);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
let resolveResponse: ((message: unknown) => void) | undefined;
|
|
const responsePromise = new Promise<unknown>(resolve => {
|
|
resolveResponse = resolve;
|
|
});
|
|
|
|
serverTransport.onmessage = async message => {
|
|
if ('method' in message) {
|
|
if (message.method === 'initialize') {
|
|
if (!('id' in message) || message.id === undefined) {
|
|
throw new Error('Expected initialize request to include an id');
|
|
}
|
|
const messageId = message.id;
|
|
await serverTransport.send({
|
|
jsonrpc: '2.0',
|
|
id: messageId,
|
|
result: {
|
|
protocolVersion: LATEST_PROTOCOL_VERSION,
|
|
capabilities: {},
|
|
serverInfo: {
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
}
|
|
}
|
|
});
|
|
} else if (message.method === 'notifications/initialized') {
|
|
// ignore
|
|
}
|
|
} else {
|
|
resolveResponse?.(message);
|
|
}
|
|
};
|
|
|
|
await client.connect(clientTransport);
|
|
|
|
// Server shouldn't send this, because the client capabilities
|
|
// only advertised URL mode. Test that it's rejected by the client:
|
|
const requestId = 1;
|
|
await serverTransport.send({
|
|
jsonrpc: '2.0',
|
|
id: requestId,
|
|
method: 'elicitation/create',
|
|
params: {
|
|
mode: 'form',
|
|
message: 'Provide your username',
|
|
requestedSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
username: {
|
|
type: 'string'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
const response = (await responsePromise) as { id: number; error: { code: number; message: string } };
|
|
|
|
expect(response.id).toBe(requestId);
|
|
expect(response.error.code).toBe(ErrorCode.InvalidParams);
|
|
expect(response.error.message).toContain('Client does not support form-mode elicitation requests');
|
|
expect(handler).not.toHaveBeenCalled();
|
|
|
|
await client.close();
|
|
});
|
|
|
|
test('should reject missing-mode elicitation when client only supports URL mode', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test server',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {}
|
|
}
|
|
);
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test client',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
elicitation: {
|
|
url: {}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const handler = vi.fn().mockResolvedValue({
|
|
action: 'cancel'
|
|
});
|
|
client.setRequestHandler(ElicitRequestSchema, handler);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
await expect(
|
|
server.request(
|
|
{
|
|
method: 'elicitation/create',
|
|
params: {
|
|
message: 'Please provide data',
|
|
requestedSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
username: {
|
|
type: 'string'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
ElicitResultSchema
|
|
)
|
|
).rejects.toThrow('Client does not support form-mode elicitation requests');
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
|
|
await Promise.all([client.close(), server.close()]);
|
|
});
|
|
|
|
test('should reject URL-mode elicitation when client only supports form mode', async () => {
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
elicitation: {
|
|
form: {}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const handler = vi.fn().mockResolvedValue({
|
|
action: 'cancel'
|
|
});
|
|
client.setRequestHandler(ElicitRequestSchema, handler);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
let resolveResponse: ((message: unknown) => void) | undefined;
|
|
const responsePromise = new Promise<unknown>(resolve => {
|
|
resolveResponse = resolve;
|
|
});
|
|
|
|
serverTransport.onmessage = async message => {
|
|
if ('method' in message) {
|
|
if (message.method === 'initialize') {
|
|
if (!('id' in message) || message.id === undefined) {
|
|
throw new Error('Expected initialize request to include an id');
|
|
}
|
|
const messageId = message.id;
|
|
await serverTransport.send({
|
|
jsonrpc: '2.0',
|
|
id: messageId,
|
|
result: {
|
|
protocolVersion: LATEST_PROTOCOL_VERSION,
|
|
capabilities: {},
|
|
serverInfo: {
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
}
|
|
}
|
|
});
|
|
} else if (message.method === 'notifications/initialized') {
|
|
// ignore
|
|
}
|
|
} else {
|
|
resolveResponse?.(message);
|
|
}
|
|
};
|
|
|
|
await client.connect(clientTransport);
|
|
|
|
// Server shouldn't send this, because the client capabilities
|
|
// only advertised form mode. Test that it's rejected by the client:
|
|
const requestId = 2;
|
|
await serverTransport.send({
|
|
jsonrpc: '2.0',
|
|
id: requestId,
|
|
method: 'elicitation/create',
|
|
params: {
|
|
mode: 'url',
|
|
message: 'Open the authorization page',
|
|
elicitationId: 'elicitation-123',
|
|
url: 'https://example.com/authorize'
|
|
}
|
|
});
|
|
|
|
const response = (await responsePromise) as { id: number; error: { code: number; message: string } };
|
|
|
|
expect(response.id).toBe(requestId);
|
|
expect(response.error.code).toBe(ErrorCode.InvalidParams);
|
|
expect(response.error.message).toContain('Client does not support URL-mode elicitation requests');
|
|
expect(handler).not.toHaveBeenCalled();
|
|
|
|
await client.close();
|
|
});
|
|
|
|
test('should apply defaults for form-mode elicitation when applyDefaults is enabled', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test server',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
prompts: {},
|
|
resources: {},
|
|
tools: {},
|
|
logging: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test client',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
elicitation: {
|
|
form: {
|
|
applyDefaults: true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
client.setRequestHandler(ElicitRequestSchema, request => {
|
|
expect(request.params.mode).toBe('form');
|
|
return {
|
|
action: 'accept',
|
|
content: {}
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
const result = await server.elicitInput({
|
|
mode: 'form',
|
|
message: 'Please confirm your preferences',
|
|
requestedSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
confirmed: {
|
|
type: 'boolean',
|
|
default: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
expect(result.action).toBe('accept');
|
|
expect(result.content).toEqual({
|
|
confirmed: true
|
|
});
|
|
|
|
await client.close();
|
|
});
|
|
|
|
/***
|
|
* Test: Handle Client Cancelling a Request
|
|
*/
|
|
test('should handle client cancelling a request', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test server',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
resources: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
// Set up server to delay responding to listResources
|
|
server.setRequestHandler(ListResourcesRequestSchema, async (request, extra) => {
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
return {
|
|
resources: []
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test client',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {}
|
|
}
|
|
);
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Set up abort controller
|
|
const controller = new AbortController();
|
|
|
|
// Issue request but cancel it immediately
|
|
const listResourcesPromise = client.listResources(undefined, {
|
|
signal: controller.signal
|
|
});
|
|
controller.abort('Cancelled by test');
|
|
|
|
// Request should be rejected with an McpError
|
|
await expect(listResourcesPromise).rejects.toThrow(McpError);
|
|
});
|
|
|
|
/***
|
|
* Test: Handle Request Timeout
|
|
*/
|
|
test('should handle request timeout', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test server',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
resources: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
// Set up server with a delayed response
|
|
server.setRequestHandler(ListResourcesRequestSchema, async (_request, extra) => {
|
|
const timer = new Promise(resolve => {
|
|
const timeout = setTimeout(resolve, 100);
|
|
extra.signal.addEventListener('abort', () => clearTimeout(timeout));
|
|
});
|
|
|
|
await timer;
|
|
return {
|
|
resources: []
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test client',
|
|
version: '1.0'
|
|
},
|
|
{
|
|
capabilities: {}
|
|
}
|
|
);
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Request with 0 msec timeout should fail immediately
|
|
await expect(client.listResources(undefined, { timeout: 0 })).rejects.toMatchObject({
|
|
code: ErrorCode.RequestTimeout
|
|
});
|
|
});
|
|
|
|
/***
|
|
* Test: Handle Tool List Changed Notifications with Auto Refresh
|
|
*/
|
|
test('should handle tool list changed notification with auto refresh', async () => {
|
|
// List changed notifications
|
|
const notifications: [Error | null, Tool[] | null][] = [];
|
|
|
|
const server = new McpServer({
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
// Register initial tool to enable the tools capability
|
|
server.registerTool(
|
|
'initial-tool',
|
|
{
|
|
description: 'Initial tool'
|
|
},
|
|
async () => ({ content: [] })
|
|
);
|
|
|
|
// Configure listChanged handler in constructor
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
listChanged: {
|
|
tools: {
|
|
onChanged: (err, tools) => {
|
|
notifications.push([err, tools]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
const result1 = await client.listTools();
|
|
expect(result1.tools).toHaveLength(1);
|
|
|
|
// Register another tool - this triggers listChanged notification
|
|
server.registerTool(
|
|
'test-tool',
|
|
{
|
|
description: 'A test tool'
|
|
},
|
|
async () => ({ content: [] })
|
|
);
|
|
|
|
// Wait for the debounced notifications to be processed
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
// Should be 1 notification with 2 tools because autoRefresh is true
|
|
expect(notifications).toHaveLength(1);
|
|
expect(notifications[0][0]).toBeNull();
|
|
expect(notifications[0][1]).toHaveLength(2);
|
|
expect(notifications[0][1]?.[1].name).toBe('test-tool');
|
|
});
|
|
|
|
/***
|
|
* Test: Handle Tool List Changed Notifications with Manual Refresh
|
|
*/
|
|
test('should handle tool list changed notification with manual refresh', async () => {
|
|
// List changed notifications
|
|
const notifications: [Error | null, Tool[] | null][] = [];
|
|
|
|
const server = new McpServer({
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
// Register initial tool to enable the tools capability
|
|
server.registerTool('initial-tool', {}, async () => ({ content: [] }));
|
|
|
|
// Configure listChanged handler with manual refresh (autoRefresh: false)
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
listChanged: {
|
|
tools: {
|
|
autoRefresh: false,
|
|
debounceMs: 0,
|
|
onChanged: (err, tools) => {
|
|
notifications.push([err, tools]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
const result1 = await client.listTools();
|
|
expect(result1.tools).toHaveLength(1);
|
|
|
|
// Register another tool - this triggers listChanged notification
|
|
server.registerTool(
|
|
'test-tool',
|
|
{
|
|
description: 'A test tool'
|
|
},
|
|
async () => ({ content: [] })
|
|
);
|
|
|
|
// Wait for the notifications to be processed (no debounce)
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
// Should be 1 notification with no tool data because autoRefresh is false
|
|
expect(notifications).toHaveLength(1);
|
|
expect(notifications[0][0]).toBeNull();
|
|
expect(notifications[0][1]).toBeNull();
|
|
});
|
|
|
|
/***
|
|
* Test: Handle Prompt List Changed Notifications
|
|
*/
|
|
test('should handle prompt list changed notification with auto refresh', async () => {
|
|
const notifications: [Error | null, Prompt[] | null][] = [];
|
|
|
|
const server = new McpServer({
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
// Register initial prompt to enable the prompts capability
|
|
server.registerPrompt(
|
|
'initial-prompt',
|
|
{
|
|
description: 'Initial prompt'
|
|
},
|
|
async () => ({
|
|
messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }]
|
|
})
|
|
);
|
|
|
|
// Configure listChanged handler in constructor
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
listChanged: {
|
|
prompts: {
|
|
onChanged: (err, prompts) => {
|
|
notifications.push([err, prompts]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
const result1 = await client.listPrompts();
|
|
expect(result1.prompts).toHaveLength(1);
|
|
|
|
// Register another prompt - this triggers listChanged notification
|
|
server.registerPrompt('test-prompt', { description: 'A test prompt' }, async () => ({
|
|
messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }]
|
|
}));
|
|
|
|
// Wait for the debounced notifications to be processed
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
// Should be 1 notification with 2 prompts because autoRefresh is true
|
|
expect(notifications).toHaveLength(1);
|
|
expect(notifications[0][0]).toBeNull();
|
|
expect(notifications[0][1]).toHaveLength(2);
|
|
expect(notifications[0][1]?.[1].name).toBe('test-prompt');
|
|
});
|
|
|
|
/***
|
|
* Test: Handle Resource List Changed Notifications
|
|
*/
|
|
test('should handle resource list changed notification with auto refresh', async () => {
|
|
const notifications: [Error | null, Resource[] | null][] = [];
|
|
|
|
const server = new McpServer({
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
// Register initial resource to enable the resources capability
|
|
server.registerResource('initial-resource', 'file:///initial.txt', {}, async () => ({
|
|
contents: [{ uri: 'file:///initial.txt', text: 'Hello' }]
|
|
}));
|
|
|
|
// Configure listChanged handler in constructor
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
listChanged: {
|
|
resources: {
|
|
onChanged: (err, resources) => {
|
|
notifications.push([err, resources]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
const result1 = await client.listResources();
|
|
expect(result1.resources).toHaveLength(1);
|
|
|
|
// Register another resource - this triggers listChanged notification
|
|
server.registerResource('test-resource', 'file:///test.txt', {}, async () => ({
|
|
contents: [{ uri: 'file:///test.txt', text: 'Hello' }]
|
|
}));
|
|
|
|
// Wait for the debounced notifications to be processed
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
// Should be 1 notification with 2 resources because autoRefresh is true
|
|
expect(notifications).toHaveLength(1);
|
|
expect(notifications[0][0]).toBeNull();
|
|
expect(notifications[0][1]).toHaveLength(2);
|
|
expect(notifications[0][1]?.[1].name).toBe('test-resource');
|
|
});
|
|
|
|
/***
|
|
* Test: Handle Multiple List Changed Handlers
|
|
*/
|
|
test('should handle multiple list changed handlers configured together', async () => {
|
|
const toolNotifications: [Error | null, Tool[] | null][] = [];
|
|
const promptNotifications: [Error | null, Prompt[] | null][] = [];
|
|
|
|
const server = new McpServer({
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
// Register initial tool and prompt to enable capabilities
|
|
server.registerTool(
|
|
'tool-1',
|
|
{
|
|
description: 'Tool 1'
|
|
},
|
|
async () => ({ content: [] })
|
|
);
|
|
server.registerPrompt(
|
|
'prompt-1',
|
|
{
|
|
description: 'Prompt 1'
|
|
},
|
|
async () => ({
|
|
messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }]
|
|
})
|
|
);
|
|
|
|
// Configure multiple listChanged handlers in constructor
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
listChanged: {
|
|
tools: {
|
|
debounceMs: 0,
|
|
onChanged: (err, tools) => {
|
|
toolNotifications.push([err, tools]);
|
|
}
|
|
},
|
|
prompts: {
|
|
debounceMs: 0,
|
|
onChanged: (err, prompts) => {
|
|
promptNotifications.push([err, prompts]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Register another tool and prompt to trigger notifications
|
|
server.registerTool(
|
|
'tool-2',
|
|
{
|
|
description: 'Tool 2'
|
|
},
|
|
async () => ({ content: [] })
|
|
);
|
|
server.registerPrompt(
|
|
'prompt-2',
|
|
{
|
|
description: 'Prompt 2'
|
|
},
|
|
async () => ({
|
|
messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }]
|
|
})
|
|
);
|
|
|
|
// Wait for notifications to be processed
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
// Both handlers should have received their respective notifications
|
|
expect(toolNotifications).toHaveLength(1);
|
|
expect(toolNotifications[0][1]).toHaveLength(2);
|
|
|
|
expect(promptNotifications).toHaveLength(1);
|
|
expect(promptNotifications[0][1]).toHaveLength(2);
|
|
});
|
|
|
|
/***
|
|
* Test: Handler not activated when server doesn't advertise listChanged capability
|
|
*/
|
|
test('should not activate listChanged handler when server does not advertise capability', async () => {
|
|
const notifications: [Error | null, Tool[] | null][] = [];
|
|
|
|
// Server with tools capability but WITHOUT listChanged
|
|
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
|
|
server.setRequestHandler(InitializeRequestSchema, async request => ({
|
|
protocolVersion: request.params.protocolVersion,
|
|
capabilities: { tools: {} }, // No listChanged: true
|
|
serverInfo: { name: 'test-server', version: '1.0.0' }
|
|
}));
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: [{ name: 'test-tool', inputSchema: { type: 'object' } }]
|
|
}));
|
|
|
|
// Configure listChanged handler that should NOT be activated
|
|
const client = new Client(
|
|
{ name: 'test-client', version: '1.0.0' },
|
|
{
|
|
listChanged: {
|
|
tools: {
|
|
debounceMs: 0,
|
|
onChanged: (err, tools) => {
|
|
notifications.push([err, tools]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Verify server doesn't have tools.listChanged capability
|
|
expect(client.getServerCapabilities()?.tools?.listChanged).toBeFalsy();
|
|
|
|
// Send a tool list changed notification manually
|
|
await server.notification({ method: 'notifications/tools/list_changed' });
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
// Handler should NOT have been activated because server didn't advertise listChanged
|
|
expect(notifications).toHaveLength(0);
|
|
});
|
|
|
|
/***
|
|
* Test: Handler activated when server advertises listChanged capability
|
|
*/
|
|
test('should activate listChanged handler when server advertises capability', async () => {
|
|
const notifications: [Error | null, Tool[] | null][] = [];
|
|
|
|
// Server with tools.listChanged: true capability
|
|
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } });
|
|
|
|
server.setRequestHandler(InitializeRequestSchema, async request => ({
|
|
protocolVersion: request.params.protocolVersion,
|
|
capabilities: { tools: { listChanged: true } },
|
|
serverInfo: { name: 'test-server', version: '1.0.0' }
|
|
}));
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: [{ name: 'test-tool', inputSchema: { type: 'object' } }]
|
|
}));
|
|
|
|
// Configure listChanged handler that SHOULD be activated
|
|
const client = new Client(
|
|
{ name: 'test-client', version: '1.0.0' },
|
|
{
|
|
listChanged: {
|
|
tools: {
|
|
debounceMs: 0,
|
|
onChanged: (err, tools) => {
|
|
notifications.push([err, tools]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Verify server has tools.listChanged capability
|
|
expect(client.getServerCapabilities()?.tools?.listChanged).toBe(true);
|
|
|
|
// Send a tool list changed notification
|
|
await server.notification({ method: 'notifications/tools/list_changed' });
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
// Handler SHOULD have been called
|
|
expect(notifications).toHaveLength(1);
|
|
expect(notifications[0][0]).toBeNull();
|
|
expect(notifications[0][1]).toHaveLength(1);
|
|
});
|
|
|
|
/***
|
|
* Test: No handlers activated when server has no listChanged capabilities
|
|
*/
|
|
test('should not activate any handlers when server has no listChanged capabilities', async () => {
|
|
const toolNotifications: [Error | null, Tool[] | null][] = [];
|
|
const promptNotifications: [Error | null, Prompt[] | null][] = [];
|
|
const resourceNotifications: [Error | null, Resource[] | null][] = [];
|
|
|
|
// Server with capabilities but NO listChanged for any
|
|
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: {}, prompts: {}, resources: {} } });
|
|
|
|
server.setRequestHandler(InitializeRequestSchema, async request => ({
|
|
protocolVersion: request.params.protocolVersion,
|
|
capabilities: { tools: {}, prompts: {}, resources: {} },
|
|
serverInfo: { name: 'test-server', version: '1.0.0' }
|
|
}));
|
|
|
|
// Configure listChanged handlers for all three types
|
|
const client = new Client(
|
|
{ name: 'test-client', version: '1.0.0' },
|
|
{
|
|
listChanged: {
|
|
tools: {
|
|
debounceMs: 0,
|
|
onChanged: (err, tools) => toolNotifications.push([err, tools])
|
|
},
|
|
prompts: {
|
|
debounceMs: 0,
|
|
onChanged: (err, prompts) => promptNotifications.push([err, prompts])
|
|
},
|
|
resources: {
|
|
debounceMs: 0,
|
|
onChanged: (err, resources) => resourceNotifications.push([err, resources])
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Verify server has no listChanged capabilities
|
|
const caps = client.getServerCapabilities();
|
|
expect(caps?.tools?.listChanged).toBeFalsy();
|
|
expect(caps?.prompts?.listChanged).toBeFalsy();
|
|
expect(caps?.resources?.listChanged).toBeFalsy();
|
|
|
|
// Send notifications for all three types
|
|
await server.notification({ method: 'notifications/tools/list_changed' });
|
|
await server.notification({ method: 'notifications/prompts/list_changed' });
|
|
await server.notification({ method: 'notifications/resources/list_changed' });
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
// No handlers should have been activated
|
|
expect(toolNotifications).toHaveLength(0);
|
|
expect(promptNotifications).toHaveLength(0);
|
|
expect(resourceNotifications).toHaveLength(0);
|
|
});
|
|
|
|
/***
|
|
* Test: Partial capability support - some handlers activated, others not
|
|
*/
|
|
test('should handle partial listChanged capability support', async () => {
|
|
const toolNotifications: [Error | null, Tool[] | null][] = [];
|
|
const promptNotifications: [Error | null, Prompt[] | null][] = [];
|
|
|
|
// Server with tools.listChanged: true but prompts without listChanged
|
|
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: { listChanged: true }, prompts: {} } });
|
|
|
|
server.setRequestHandler(InitializeRequestSchema, async request => ({
|
|
protocolVersion: request.params.protocolVersion,
|
|
capabilities: { tools: { listChanged: true }, prompts: {} },
|
|
serverInfo: { name: 'test-server', version: '1.0.0' }
|
|
}));
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: [{ name: 'tool-1', inputSchema: { type: 'object' } }]
|
|
}));
|
|
|
|
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
|
prompts: [{ name: 'prompt-1' }]
|
|
}));
|
|
|
|
const client = new Client(
|
|
{ name: 'test-client', version: '1.0.0' },
|
|
{
|
|
listChanged: {
|
|
tools: {
|
|
debounceMs: 0,
|
|
onChanged: (err, tools) => toolNotifications.push([err, tools])
|
|
},
|
|
prompts: {
|
|
debounceMs: 0,
|
|
onChanged: (err, prompts) => promptNotifications.push([err, prompts])
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Verify capability state
|
|
expect(client.getServerCapabilities()?.tools?.listChanged).toBe(true);
|
|
expect(client.getServerCapabilities()?.prompts?.listChanged).toBeFalsy();
|
|
|
|
// Send notifications for both
|
|
await server.notification({ method: 'notifications/tools/list_changed' });
|
|
await server.notification({ method: 'notifications/prompts/list_changed' });
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
// Tools handler should have been called
|
|
expect(toolNotifications).toHaveLength(1);
|
|
// Prompts handler should NOT have been called (no prompts.listChanged)
|
|
expect(promptNotifications).toHaveLength(0);
|
|
});
|
|
|
|
describe('outputSchema validation', () => {
|
|
/***
|
|
* Test: Validate structuredContent Against outputSchema
|
|
*/
|
|
test('should validate structuredContent against outputSchema', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
// Set up server handlers
|
|
server.setRequestHandler(InitializeRequestSchema, async request => ({
|
|
protocolVersion: request.params.protocolVersion,
|
|
capabilities: {},
|
|
serverInfo: {
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
}
|
|
}));
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: [
|
|
{
|
|
name: 'test-tool',
|
|
description: 'A test tool',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {}
|
|
},
|
|
outputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
result: { type: 'string' },
|
|
count: { type: 'number' }
|
|
},
|
|
required: ['result', 'count'],
|
|
additionalProperties: false
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async request => {
|
|
if (request.params.name === 'test-tool') {
|
|
return {
|
|
structuredContent: { result: 'success', count: 42 }
|
|
};
|
|
}
|
|
throw new Error('Unknown tool');
|
|
});
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tasks: {
|
|
requests: {
|
|
tools: {
|
|
call: {}
|
|
},
|
|
tasks: {
|
|
get: true,
|
|
list: {},
|
|
result: true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// List tools to cache the schemas
|
|
await client.listTools();
|
|
|
|
// Call the tool - should validate successfully
|
|
const result = await client.callTool({ name: 'test-tool' });
|
|
expect(result.structuredContent).toEqual({ result: 'success', count: 42 });
|
|
});
|
|
|
|
/***
|
|
* Test: Throw Error when structuredContent Does Not Match Schema
|
|
*/
|
|
test('should throw error when structuredContent does not match schema', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
// Set up server handlers
|
|
server.setRequestHandler(InitializeRequestSchema, async request => ({
|
|
protocolVersion: request.params.protocolVersion,
|
|
capabilities: {},
|
|
serverInfo: {
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
}
|
|
}));
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: [
|
|
{
|
|
name: 'test-tool',
|
|
description: 'A test tool',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {}
|
|
},
|
|
outputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
result: { type: 'string' },
|
|
count: { type: 'number' }
|
|
},
|
|
required: ['result', 'count'],
|
|
additionalProperties: false
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async request => {
|
|
if (request.params.name === 'test-tool') {
|
|
// Return invalid structured content (count is string instead of number)
|
|
return {
|
|
structuredContent: { result: 'success', count: 'not a number' }
|
|
};
|
|
}
|
|
throw new Error('Unknown tool');
|
|
});
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tasks: {
|
|
requests: {
|
|
tools: {
|
|
call: {}
|
|
},
|
|
tasks: {
|
|
get: true,
|
|
list: {},
|
|
result: true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// List tools to cache the schemas
|
|
await client.listTools();
|
|
|
|
// Call the tool - should throw validation error
|
|
await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow(/Structured content does not match the tool's output schema/);
|
|
});
|
|
|
|
/***
|
|
* Test: Throw Error when Tool with outputSchema Returns No structuredContent
|
|
*/
|
|
test('should throw error when tool with outputSchema returns no structuredContent', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
// Set up server handlers
|
|
server.setRequestHandler(InitializeRequestSchema, async request => ({
|
|
protocolVersion: request.params.protocolVersion,
|
|
capabilities: {},
|
|
serverInfo: {
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
}
|
|
}));
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: [
|
|
{
|
|
name: 'test-tool',
|
|
description: 'A test tool',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {}
|
|
},
|
|
outputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
result: { type: 'string' }
|
|
},
|
|
required: ['result']
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async request => {
|
|
if (request.params.name === 'test-tool') {
|
|
// Return content instead of structuredContent
|
|
return {
|
|
content: [{ type: 'text', text: 'This should be structured content' }]
|
|
};
|
|
}
|
|
throw new Error('Unknown tool');
|
|
});
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tasks: {
|
|
requests: {
|
|
tools: {
|
|
call: {}
|
|
},
|
|
tasks: {
|
|
get: true,
|
|
list: {},
|
|
result: true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// List tools to cache the schemas
|
|
await client.listTools();
|
|
|
|
// Call the tool - should throw error
|
|
await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow(
|
|
/Tool test-tool has an output schema but did not return structured content/
|
|
);
|
|
});
|
|
|
|
/***
|
|
* Test: Handle Tools Without outputSchema Normally
|
|
*/
|
|
test('should handle tools without outputSchema normally', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
// Set up server handlers
|
|
server.setRequestHandler(InitializeRequestSchema, async request => ({
|
|
protocolVersion: request.params.protocolVersion,
|
|
capabilities: {},
|
|
serverInfo: {
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
}
|
|
}));
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: [
|
|
{
|
|
name: 'test-tool',
|
|
description: 'A test tool',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {}
|
|
}
|
|
// No outputSchema
|
|
}
|
|
]
|
|
}));
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async request => {
|
|
if (request.params.name === 'test-tool') {
|
|
// Return regular content
|
|
return {
|
|
content: [{ type: 'text', text: 'Normal response' }]
|
|
};
|
|
}
|
|
throw new Error('Unknown tool');
|
|
});
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tasks: {
|
|
requests: {
|
|
tools: {
|
|
call: {}
|
|
},
|
|
tasks: {
|
|
get: true,
|
|
list: {},
|
|
result: true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// List tools to cache the schemas
|
|
await client.listTools();
|
|
|
|
// Call the tool - should work normally without validation
|
|
const result = await client.callTool({ name: 'test-tool' });
|
|
expect(result.content).toEqual([{ type: 'text', text: 'Normal response' }]);
|
|
});
|
|
|
|
/***
|
|
* Test: Handle Complex JSON Schema Validation
|
|
*/
|
|
test('should handle complex JSON schema validation', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
// Set up server handlers
|
|
server.setRequestHandler(InitializeRequestSchema, async request => ({
|
|
protocolVersion: request.params.protocolVersion,
|
|
capabilities: {},
|
|
serverInfo: {
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
}
|
|
}));
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: [
|
|
{
|
|
name: 'complex-tool',
|
|
description: 'A tool with complex schema',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {}
|
|
},
|
|
outputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
name: { type: 'string', minLength: 3 },
|
|
age: { type: 'integer', minimum: 0, maximum: 120 },
|
|
active: { type: 'boolean' },
|
|
tags: {
|
|
type: 'array',
|
|
items: { type: 'string' },
|
|
minItems: 1
|
|
},
|
|
metadata: {
|
|
type: 'object',
|
|
properties: {
|
|
created: { type: 'string' }
|
|
},
|
|
required: ['created']
|
|
}
|
|
},
|
|
required: ['name', 'age', 'active', 'tags', 'metadata'],
|
|
additionalProperties: false
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async request => {
|
|
if (request.params.name === 'complex-tool') {
|
|
return {
|
|
structuredContent: {
|
|
name: 'John Doe',
|
|
age: 30,
|
|
active: true,
|
|
tags: ['user', 'admin'],
|
|
metadata: {
|
|
created: '2023-01-01T00:00:00Z'
|
|
}
|
|
}
|
|
};
|
|
}
|
|
throw new Error('Unknown tool');
|
|
});
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tasks: {
|
|
requests: {
|
|
tools: {
|
|
call: {}
|
|
},
|
|
tasks: {
|
|
get: true,
|
|
list: {},
|
|
result: true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// List tools to cache the schemas
|
|
await client.listTools();
|
|
|
|
// Call the tool - should validate successfully
|
|
const result = await client.callTool({ name: 'complex-tool' });
|
|
expect(result.structuredContent).toBeDefined();
|
|
const structuredContent = result.structuredContent as { name: string; age: number };
|
|
expect(structuredContent.name).toBe('John Doe');
|
|
expect(structuredContent.age).toBe(30);
|
|
});
|
|
|
|
/***
|
|
* Test: Fail Validation with Additional Properties When Not Allowed
|
|
*/
|
|
test('should fail validation with additional properties when not allowed', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
// Set up server handlers
|
|
server.setRequestHandler(InitializeRequestSchema, async request => ({
|
|
protocolVersion: request.params.protocolVersion,
|
|
capabilities: {},
|
|
serverInfo: {
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
}
|
|
}));
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: [
|
|
{
|
|
name: 'strict-tool',
|
|
description: 'A tool with strict schema',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {}
|
|
},
|
|
outputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
name: { type: 'string' }
|
|
},
|
|
required: ['name'],
|
|
additionalProperties: false
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async request => {
|
|
if (request.params.name === 'strict-tool') {
|
|
// Return structured content with extra property
|
|
return {
|
|
structuredContent: {
|
|
name: 'John',
|
|
extraField: 'not allowed'
|
|
}
|
|
};
|
|
}
|
|
throw new Error('Unknown tool');
|
|
});
|
|
|
|
const client = new Client({
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// List tools to cache the schemas
|
|
await client.listTools();
|
|
|
|
// Call the tool - should throw validation error due to additional property
|
|
await expect(client.callTool({ name: 'strict-tool' })).rejects.toThrow(
|
|
/Structured content does not match the tool's output schema/
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Task-based execution', () => {
|
|
describe('Client calling server', () => {
|
|
let serverTaskStore: InMemoryTaskStore;
|
|
|
|
beforeEach(() => {
|
|
serverTaskStore = new InMemoryTaskStore();
|
|
});
|
|
|
|
afterEach(() => {
|
|
serverTaskStore?.cleanup();
|
|
});
|
|
|
|
test('should create task on server via tool call', async () => {
|
|
const server = new McpServer(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tasks: {
|
|
requests: {
|
|
tools: {
|
|
call: {}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
taskStore: serverTaskStore
|
|
}
|
|
);
|
|
|
|
server.experimental.tasks.registerToolTask(
|
|
'test-tool',
|
|
{
|
|
description: 'A test tool',
|
|
inputSchema: {}
|
|
},
|
|
{
|
|
async createTask(_args, extra) {
|
|
const task = await extra.taskStore.createTask({
|
|
ttl: extra.taskRequestedTtl
|
|
});
|
|
|
|
const result = {
|
|
content: [{ type: 'text', text: 'Tool executed successfully!' }]
|
|
};
|
|
await extra.taskStore.storeTaskResult(task.taskId, 'completed', result);
|
|
|
|
return { task };
|
|
},
|
|
async getTask(_args, extra) {
|
|
const task = await extra.taskStore.getTask(extra.taskId);
|
|
if (!task) {
|
|
throw new Error(`Task ${extra.taskId} not found`);
|
|
}
|
|
return task;
|
|
},
|
|
async getTaskResult(_args, extra) {
|
|
const result = await extra.taskStore.getTaskResult(extra.taskId);
|
|
return result as { content: Array<{ type: 'text'; text: string }> };
|
|
}
|
|
}
|
|
);
|
|
|
|
const client = new Client({
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Client creates task on server via tool call
|
|
await client.callTool({ name: 'test-tool', arguments: {} }, CallToolResultSchema, {
|
|
task: {
|
|
ttl: 60000
|
|
}
|
|
});
|
|
|
|
// Verify task was created successfully by listing tasks
|
|
const taskList = await client.experimental.tasks.listTasks();
|
|
expect(taskList.tasks.length).toBeGreaterThan(0);
|
|
const task = taskList.tasks[0];
|
|
expect(task.status).toBe('completed');
|
|
});
|
|
|
|
test('should query task status from server using getTask', async () => {
|
|
const server = new McpServer(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tasks: {
|
|
requests: {
|
|
tools: {
|
|
call: {}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
taskStore: serverTaskStore
|
|
}
|
|
);
|
|
|
|
server.experimental.tasks.registerToolTask(
|
|
'test-tool',
|
|
{
|
|
description: 'A test tool',
|
|
inputSchema: {}
|
|
},
|
|
{
|
|
async createTask(_args, extra) {
|
|
const task = await extra.taskStore.createTask({
|
|
ttl: extra.taskRequestedTtl
|
|
});
|
|
|
|
const result = {
|
|
content: [{ type: 'text', text: 'Success!' }]
|
|
};
|
|
await extra.taskStore.storeTaskResult(task.taskId, 'completed', result);
|
|
|
|
return { task };
|
|
},
|
|
async getTask(_args, extra) {
|
|
const task = await extra.taskStore.getTask(extra.taskId);
|
|
if (!task) {
|
|
throw new Error(`Task ${extra.taskId} not found`);
|
|
}
|
|
return task;
|
|
},
|
|
async getTaskResult(_args, extra) {
|
|
const result = await extra.taskStore.getTaskResult(extra.taskId);
|
|
return result as { content: Array<{ type: 'text'; text: string }> };
|
|
}
|
|
}
|
|
);
|
|
|
|
const client = new Client({
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Create a task
|
|
await client.callTool({ name: 'test-tool', arguments: {} }, CallToolResultSchema, {
|
|
task: { ttl: 60000 }
|
|
});
|
|
|
|
// Query task status by listing tasks and getting the first one
|
|
const taskList = await client.experimental.tasks.listTasks();
|
|
expect(taskList.tasks.length).toBeGreaterThan(0);
|
|
const task = taskList.tasks[0];
|
|
expect(task).toBeDefined();
|
|
expect(task.taskId).toBeDefined();
|
|
expect(task.status).toBe('completed');
|
|
});
|
|
|
|
test('should query task result from server using getTaskResult', async () => {
|
|
const server = new McpServer(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tasks: {
|
|
requests: {
|
|
tools: {
|
|
call: {},
|
|
list: {}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
taskStore: serverTaskStore
|
|
}
|
|
);
|
|
|
|
server.experimental.tasks.registerToolTask(
|
|
'test-tool',
|
|
{
|
|
description: 'A test tool',
|
|
inputSchema: {}
|
|
},
|
|
{
|
|
async createTask(_args, extra) {
|
|
const task = await extra.taskStore.createTask({
|
|
ttl: extra.taskRequestedTtl
|
|
});
|
|
|
|
const result = {
|
|
content: [{ type: 'text', text: 'Result data!' }]
|
|
};
|
|
await extra.taskStore.storeTaskResult(task.taskId, 'completed', result);
|
|
|
|
return { task };
|
|
},
|
|
async getTask(_args, extra) {
|
|
const task = await extra.taskStore.getTask(extra.taskId);
|
|
if (!task) {
|
|
throw new Error(`Task ${extra.taskId} not found`);
|
|
}
|
|
return task;
|
|
},
|
|
async getTaskResult(_args, extra) {
|
|
const result = await extra.taskStore.getTaskResult(extra.taskId);
|
|
return result as { content: Array<{ type: 'text'; text: string }> };
|
|
}
|
|
}
|
|
);
|
|
|
|
const client = new Client({
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Create a task using callToolStream to capture the task ID
|
|
let taskId: string | undefined;
|
|
const stream = client.experimental.tasks.callToolStream({ name: 'test-tool', arguments: {} }, CallToolResultSchema, {
|
|
task: { ttl: 60000 }
|
|
});
|
|
|
|
for await (const message of stream) {
|
|
if (message.type === 'taskCreated') {
|
|
taskId = message.task.taskId;
|
|
}
|
|
}
|
|
|
|
expect(taskId).toBeDefined();
|
|
|
|
// Query task result using the captured task ID
|
|
const result = await client.experimental.tasks.getTaskResult(taskId!, CallToolResultSchema);
|
|
expect(result.content).toEqual([{ type: 'text', text: 'Result data!' }]);
|
|
});
|
|
|
|
test('should query task list from server using listTasks', async () => {
|
|
const server = new McpServer(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tasks: {
|
|
requests: {
|
|
tools: {
|
|
call: {}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
taskStore: serverTaskStore
|
|
}
|
|
);
|
|
|
|
server.experimental.tasks.registerToolTask(
|
|
'test-tool',
|
|
{
|
|
description: 'A test tool',
|
|
inputSchema: {}
|
|
},
|
|
{
|
|
async createTask(_args, extra) {
|
|
const task = await extra.taskStore.createTask({
|
|
ttl: extra.taskRequestedTtl
|
|
});
|
|
|
|
const result = {
|
|
content: [{ type: 'text', text: 'Success!' }]
|
|
};
|
|
await extra.taskStore.storeTaskResult(task.taskId, 'completed', result);
|
|
|
|
return { task };
|
|
},
|
|
async getTask(_args, extra) {
|
|
const task = await extra.taskStore.getTask(extra.taskId);
|
|
if (!task) {
|
|
throw new Error(`Task ${extra.taskId} not found`);
|
|
}
|
|
return task;
|
|
},
|
|
async getTaskResult(_args, extra) {
|
|
const result = await extra.taskStore.getTaskResult(extra.taskId);
|
|
return result as { content: Array<{ type: 'text'; text: string }> };
|
|
}
|
|
}
|
|
);
|
|
|
|
const client = new Client({
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Create multiple tasks
|
|
const createdTaskIds: string[] = [];
|
|
|
|
for (let i = 0; i < 2; i++) {
|
|
await client.callTool({ name: 'test-tool', arguments: {} }, CallToolResultSchema, {
|
|
task: { ttl: 60000 }
|
|
});
|
|
|
|
// Get the task ID from the task list
|
|
const taskList = await client.experimental.tasks.listTasks();
|
|
const newTask = taskList.tasks.find(t => !createdTaskIds.includes(t.taskId));
|
|
if (newTask) {
|
|
createdTaskIds.push(newTask.taskId);
|
|
}
|
|
}
|
|
|
|
// Query task list
|
|
const taskList = await client.experimental.tasks.listTasks();
|
|
expect(taskList.tasks.length).toBeGreaterThanOrEqual(2);
|
|
for (const taskId of createdTaskIds) {
|
|
expect(taskList.tasks).toContainEqual(
|
|
expect.objectContaining({
|
|
taskId,
|
|
status: 'completed'
|
|
})
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Server calling client', () => {
|
|
let clientTaskStore: InMemoryTaskStore;
|
|
|
|
beforeEach(() => {
|
|
clientTaskStore = new InMemoryTaskStore();
|
|
});
|
|
|
|
afterEach(() => {
|
|
clientTaskStore?.cleanup();
|
|
});
|
|
|
|
test('should create task on client via server elicitation', async () => {
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
elicitation: {},
|
|
tasks: {
|
|
requests: {
|
|
elicitation: {
|
|
create: {}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
taskStore: clientTaskStore
|
|
}
|
|
);
|
|
|
|
client.setRequestHandler(ElicitRequestSchema, async (request, extra) => {
|
|
const result = {
|
|
action: 'accept',
|
|
content: { username: 'list-user' }
|
|
};
|
|
|
|
// Check if task creation is requested
|
|
if (request.params.task && extra.taskStore) {
|
|
const task = await extra.taskStore.createTask({
|
|
ttl: extra.taskRequestedTtl
|
|
});
|
|
await extra.taskStore.storeTaskResult(task.taskId, 'completed', result);
|
|
// Return CreateTaskResult when task creation is requested
|
|
return { task };
|
|
}
|
|
|
|
// Return ElicitResult for non-task requests
|
|
return result;
|
|
});
|
|
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tasks: {
|
|
requests: {
|
|
elicitation: {
|
|
create: {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Server creates task on client via elicitation
|
|
const createTaskResult = await server.request(
|
|
{
|
|
method: 'elicitation/create',
|
|
params: {
|
|
mode: 'form',
|
|
message: 'Please provide your username',
|
|
requestedSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
username: { type: 'string' }
|
|
},
|
|
required: ['username']
|
|
}
|
|
}
|
|
},
|
|
CreateTaskResultSchema,
|
|
{ task: { ttl: 60000 } }
|
|
);
|
|
|
|
// Verify CreateTaskResult structure
|
|
expect(createTaskResult.task).toBeDefined();
|
|
expect(createTaskResult.task.taskId).toBeDefined();
|
|
const taskId = createTaskResult.task.taskId;
|
|
|
|
// Verify task was created
|
|
const task = await server.experimental.tasks.getTask(taskId);
|
|
expect(task.status).toBe('completed');
|
|
});
|
|
|
|
test('should query task status from client using getTask', async () => {
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
elicitation: {},
|
|
tasks: {
|
|
requests: {
|
|
elicitation: {
|
|
create: {}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
taskStore: clientTaskStore
|
|
}
|
|
);
|
|
|
|
client.setRequestHandler(ElicitRequestSchema, async (request, extra) => {
|
|
const result = {
|
|
action: 'accept',
|
|
content: { username: 'list-user' }
|
|
};
|
|
|
|
// Check if task creation is requested
|
|
if (request.params.task && extra.taskStore) {
|
|
const task = await extra.taskStore.createTask({
|
|
ttl: extra.taskRequestedTtl
|
|
});
|
|
await extra.taskStore.storeTaskResult(task.taskId, 'completed', result);
|
|
// Return CreateTaskResult when task creation is requested
|
|
return { task };
|
|
}
|
|
|
|
// Return ElicitResult for non-task requests
|
|
return result;
|
|
});
|
|
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tasks: {
|
|
requests: {
|
|
elicitation: {
|
|
create: {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Create a task on client and wait for CreateTaskResult
|
|
const createTaskResult = await server.request(
|
|
{
|
|
method: 'elicitation/create',
|
|
params: {
|
|
mode: 'form',
|
|
message: 'Please provide info',
|
|
requestedSchema: {
|
|
type: 'object',
|
|
properties: { username: { type: 'string' } }
|
|
}
|
|
}
|
|
},
|
|
CreateTaskResultSchema,
|
|
{ task: { ttl: 60000 } }
|
|
);
|
|
|
|
// Verify CreateTaskResult structure
|
|
expect(createTaskResult.task).toBeDefined();
|
|
expect(createTaskResult.task.taskId).toBeDefined();
|
|
const taskId = createTaskResult.task.taskId;
|
|
|
|
// Query task status
|
|
const task = await server.experimental.tasks.getTask(taskId);
|
|
expect(task).toBeDefined();
|
|
expect(task.taskId).toBe(taskId);
|
|
expect(task.status).toBe('completed');
|
|
});
|
|
|
|
test('should query task result from client using getTaskResult', async () => {
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
elicitation: {},
|
|
tasks: {
|
|
requests: {
|
|
elicitation: {
|
|
create: {}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
taskStore: clientTaskStore
|
|
}
|
|
);
|
|
|
|
client.setRequestHandler(ElicitRequestSchema, async (request, extra) => {
|
|
const result = {
|
|
action: 'accept',
|
|
content: { username: 'result-user' }
|
|
};
|
|
|
|
// Check if task creation is requested
|
|
if (request.params.task && extra.taskStore) {
|
|
const task = await extra.taskStore.createTask({
|
|
ttl: extra.taskRequestedTtl
|
|
});
|
|
await extra.taskStore.storeTaskResult(task.taskId, 'completed', result);
|
|
// Return CreateTaskResult when task creation is requested
|
|
return { task };
|
|
}
|
|
|
|
// Return ElicitResult for non-task requests
|
|
return result;
|
|
});
|
|
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tasks: {
|
|
requests: {
|
|
elicitation: {
|
|
create: {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Create a task on client and wait for CreateTaskResult
|
|
const createTaskResult = await server.request(
|
|
{
|
|
method: 'elicitation/create',
|
|
params: {
|
|
mode: 'form',
|
|
message: 'Please provide info',
|
|
requestedSchema: {
|
|
type: 'object',
|
|
properties: { username: { type: 'string' } }
|
|
}
|
|
}
|
|
},
|
|
CreateTaskResultSchema,
|
|
{ task: { ttl: 60000 } }
|
|
);
|
|
|
|
// Verify CreateTaskResult structure
|
|
expect(createTaskResult.task).toBeDefined();
|
|
expect(createTaskResult.task.taskId).toBeDefined();
|
|
const taskId = createTaskResult.task.taskId;
|
|
|
|
// Query task result using getTaskResult
|
|
const taskResult = await server.experimental.tasks.getTaskResult(taskId, ElicitResultSchema);
|
|
expect(taskResult.action).toBe('accept');
|
|
expect(taskResult.content).toEqual({ username: 'result-user' });
|
|
});
|
|
|
|
test('should query task list from client using listTasks', async () => {
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
elicitation: {},
|
|
tasks: {
|
|
requests: {
|
|
elicitation: {
|
|
create: {}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
taskStore: clientTaskStore
|
|
}
|
|
);
|
|
|
|
client.setRequestHandler(ElicitRequestSchema, async (request, extra) => {
|
|
const result = {
|
|
action: 'accept',
|
|
content: { username: 'list-user' }
|
|
};
|
|
|
|
// Check if task creation is requested
|
|
if (request.params.task && extra.taskStore) {
|
|
const task = await extra.taskStore.createTask({
|
|
ttl: extra.taskRequestedTtl
|
|
});
|
|
await extra.taskStore.storeTaskResult(task.taskId, 'completed', result);
|
|
// Return CreateTaskResult when task creation is requested
|
|
return { task };
|
|
}
|
|
|
|
// Return ElicitResult for non-task requests
|
|
return result;
|
|
});
|
|
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tasks: {
|
|
requests: {
|
|
elicitation: {
|
|
create: {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Create multiple tasks on client
|
|
const createdTaskIds: string[] = [];
|
|
for (let i = 0; i < 2; i++) {
|
|
const createTaskResult = await server.request(
|
|
{
|
|
method: 'elicitation/create',
|
|
params: {
|
|
mode: 'form',
|
|
message: 'Please provide info',
|
|
requestedSchema: {
|
|
type: 'object',
|
|
properties: { username: { type: 'string' } }
|
|
}
|
|
}
|
|
},
|
|
CreateTaskResultSchema,
|
|
{ task: { ttl: 60000 } }
|
|
);
|
|
|
|
// Verify CreateTaskResult structure and capture taskId
|
|
expect(createTaskResult.task).toBeDefined();
|
|
expect(createTaskResult.task.taskId).toBeDefined();
|
|
createdTaskIds.push(createTaskResult.task.taskId);
|
|
}
|
|
|
|
// Query task list
|
|
const taskList = await server.experimental.tasks.listTasks();
|
|
expect(taskList.tasks.length).toBeGreaterThanOrEqual(2);
|
|
for (const taskId of createdTaskIds) {
|
|
expect(taskList.tasks).toContainEqual(
|
|
expect.objectContaining({
|
|
taskId,
|
|
status: 'completed'
|
|
})
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
test('should list tasks from server with pagination', async () => {
|
|
const serverTaskStore = new InMemoryTaskStore();
|
|
|
|
const server = new McpServer(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tasks: {
|
|
requests: {
|
|
tools: {
|
|
call: {}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
taskStore: serverTaskStore
|
|
}
|
|
);
|
|
|
|
server.experimental.tasks.registerToolTask(
|
|
'test-tool',
|
|
{
|
|
description: 'A test tool',
|
|
inputSchema: {
|
|
id: z4.string()
|
|
}
|
|
},
|
|
{
|
|
async createTask({ id }, extra) {
|
|
const task = await extra.taskStore.createTask({
|
|
ttl: extra.taskRequestedTtl
|
|
});
|
|
|
|
const result = {
|
|
content: [{ type: 'text', text: `Result for ${id || 'unknown'}` }]
|
|
};
|
|
await extra.taskStore.storeTaskResult(task.taskId, 'completed', result);
|
|
|
|
return { task };
|
|
},
|
|
async getTask(_args, extra) {
|
|
const task = await extra.taskStore.getTask(extra.taskId);
|
|
if (!task) {
|
|
throw new Error(`Task ${extra.taskId} not found`);
|
|
}
|
|
return task;
|
|
},
|
|
async getTaskResult(_args, extra) {
|
|
const result = await extra.taskStore.getTaskResult(extra.taskId);
|
|
return result as { content: Array<{ type: 'text'; text: string }> };
|
|
}
|
|
}
|
|
);
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tasks: {
|
|
requests: {
|
|
tools: {
|
|
call: {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Create multiple tasks
|
|
const createdTaskIds: string[] = [];
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
await client.callTool({ name: 'test-tool', arguments: { id: `task-${i + 1}` } }, CallToolResultSchema, {
|
|
task: { ttl: 60000 }
|
|
});
|
|
|
|
// Get the task ID from the task list
|
|
const taskList = await client.experimental.tasks.listTasks();
|
|
const newTask = taskList.tasks.find(t => !createdTaskIds.includes(t.taskId));
|
|
if (newTask) {
|
|
createdTaskIds.push(newTask.taskId);
|
|
}
|
|
}
|
|
|
|
// List all tasks without cursor
|
|
const firstPage = await client.experimental.tasks.listTasks();
|
|
expect(firstPage.tasks.length).toBeGreaterThan(0);
|
|
expect(firstPage.tasks.map(t => t.taskId)).toEqual(expect.arrayContaining(createdTaskIds));
|
|
|
|
// If there's a cursor, test pagination
|
|
if (firstPage.nextCursor) {
|
|
const secondPage = await client.experimental.tasks.listTasks(firstPage.nextCursor);
|
|
expect(secondPage.tasks).toBeDefined();
|
|
}
|
|
|
|
serverTaskStore.cleanup();
|
|
});
|
|
|
|
describe('Error scenarios', () => {
|
|
let serverTaskStore: InMemoryTaskStore;
|
|
let clientTaskStore: InMemoryTaskStore;
|
|
|
|
beforeEach(() => {
|
|
serverTaskStore = new InMemoryTaskStore();
|
|
clientTaskStore = new InMemoryTaskStore();
|
|
});
|
|
|
|
afterEach(() => {
|
|
serverTaskStore?.cleanup();
|
|
clientTaskStore?.cleanup();
|
|
});
|
|
|
|
test('should throw error when querying non-existent task from server', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {},
|
|
tasks: {
|
|
requests: {
|
|
tools: {
|
|
call: {}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
taskStore: serverTaskStore
|
|
}
|
|
);
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tasks: {
|
|
requests: {
|
|
tools: {
|
|
call: {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Try to get a task that doesn't exist
|
|
await expect(client.experimental.tasks.getTask('non-existent-task')).rejects.toThrow();
|
|
});
|
|
|
|
test('should throw error when querying result of non-existent task from server', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {},
|
|
tasks: {
|
|
requests: {
|
|
tools: {
|
|
call: {}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
taskStore: serverTaskStore
|
|
}
|
|
);
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tasks: {
|
|
requests: {
|
|
tools: {
|
|
call: {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Try to get result of a task that doesn't exist
|
|
await expect(client.experimental.tasks.getTaskResult('non-existent-task', CallToolResultSchema)).rejects.toThrow();
|
|
});
|
|
|
|
test('should throw error when server queries non-existent task from client', async () => {
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
elicitation: {},
|
|
tasks: {
|
|
requests: {
|
|
elicitation: {
|
|
create: {}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
taskStore: clientTaskStore
|
|
}
|
|
);
|
|
|
|
client.setRequestHandler(ElicitRequestSchema, async () => ({
|
|
action: 'accept',
|
|
content: { username: 'test' }
|
|
}));
|
|
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tasks: {
|
|
requests: {
|
|
elicitation: {
|
|
create: {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Try to query a task that doesn't exist on client
|
|
await expect(server.experimental.tasks.getTask('non-existent-task')).rejects.toThrow();
|
|
});
|
|
});
|
|
});
|
|
|
|
test('should respect server task capabilities', async () => {
|
|
const serverTaskStore = new InMemoryTaskStore();
|
|
const server = new McpServer(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tasks: {
|
|
requests: {
|
|
tools: {
|
|
call: {}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
taskStore: serverTaskStore
|
|
}
|
|
);
|
|
|
|
server.experimental.tasks.registerToolTask(
|
|
'test-tool',
|
|
{
|
|
description: 'A test tool',
|
|
inputSchema: {}
|
|
},
|
|
{
|
|
async createTask(_args, extra) {
|
|
const task = await extra.taskStore.createTask({
|
|
ttl: extra.taskRequestedTtl
|
|
});
|
|
|
|
const result = {
|
|
content: [{ type: 'text', text: 'Success!' }]
|
|
};
|
|
await extra.taskStore.storeTaskResult(task.taskId, 'completed', result);
|
|
|
|
return { task };
|
|
},
|
|
async getTask(_args, extra) {
|
|
const task = await extra.taskStore.getTask(extra.taskId);
|
|
if (!task) {
|
|
throw new Error(`Task ${extra.taskId} not found`);
|
|
}
|
|
return task;
|
|
},
|
|
async getTaskResult(_args, extra) {
|
|
const result = await extra.taskStore.getTaskResult(extra.taskId);
|
|
return result as { content: Array<{ type: 'text'; text: string }> };
|
|
}
|
|
}
|
|
);
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
enforceStrictCapabilities: true
|
|
}
|
|
);
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Server supports task creation for tools/call
|
|
expect(client.getServerCapabilities()).toEqual({
|
|
tools: {
|
|
listChanged: true
|
|
},
|
|
tasks: {
|
|
requests: {
|
|
tools: {
|
|
call: {}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// These should work because server supports tasks
|
|
await expect(
|
|
client.callTool({ name: 'test-tool', arguments: {} }, CallToolResultSchema, {
|
|
task: { ttl: 60000 }
|
|
})
|
|
).resolves.not.toThrow();
|
|
await expect(client.experimental.tasks.listTasks()).resolves.not.toThrow();
|
|
|
|
// tools/list doesn't support task creation, but it shouldn't throw - it should just ignore the task metadata
|
|
await expect(
|
|
client.request(
|
|
{
|
|
method: 'tools/list',
|
|
params: {}
|
|
},
|
|
ListToolsResultSchema
|
|
)
|
|
).resolves.not.toThrow();
|
|
|
|
serverTaskStore.cleanup();
|
|
});
|
|
|
|
/**
|
|
* Test: requestStream() method
|
|
*/
|
|
test('should expose requestStream() method for streaming responses', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async () => {
|
|
return {
|
|
content: [{ type: 'text', text: 'Tool result' }]
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {}
|
|
}
|
|
);
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// First verify that regular request() works
|
|
const regularResult = await client.callTool({ name: 'test-tool', arguments: {} });
|
|
expect(regularResult.content).toEqual([{ type: 'text', text: 'Tool result' }]);
|
|
|
|
// Test requestStream with non-task request (should yield only result)
|
|
const stream = client.experimental.tasks.requestStream(
|
|
{
|
|
method: 'tools/call',
|
|
params: { name: 'test-tool', arguments: {} }
|
|
},
|
|
CallToolResultSchema
|
|
);
|
|
|
|
const messages = [];
|
|
for await (const message of stream) {
|
|
messages.push(message);
|
|
}
|
|
|
|
// Should have received only a result message (no task messages)
|
|
expect(messages.length).toBe(1);
|
|
expect(messages[0].type).toBe('result');
|
|
if (messages[0].type === 'result') {
|
|
expect(messages[0].result.content).toEqual([{ type: 'text', text: 'Tool result' }]);
|
|
}
|
|
|
|
await client.close();
|
|
await server.close();
|
|
});
|
|
|
|
/**
|
|
* Test: callToolStream() method
|
|
*/
|
|
test('should expose callToolStream() method for streaming tool calls', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async () => {
|
|
return {
|
|
content: [{ type: 'text', text: 'Tool result' }]
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {}
|
|
}
|
|
);
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// Test callToolStream
|
|
const stream = client.experimental.tasks.callToolStream({ name: 'test-tool', arguments: {} });
|
|
|
|
const messages = [];
|
|
for await (const message of stream) {
|
|
messages.push(message);
|
|
}
|
|
|
|
// Should have received messages ending with result
|
|
expect(messages.length).toBe(1);
|
|
expect(messages[0].type).toBe('result');
|
|
if (messages[0].type === 'result') {
|
|
expect(messages[0].result.content).toEqual([{ type: 'text', text: 'Tool result' }]);
|
|
}
|
|
|
|
await client.close();
|
|
await server.close();
|
|
});
|
|
|
|
/**
|
|
* Test: callToolStream() with output schema validation
|
|
*/
|
|
test('should validate structured output in callToolStream()', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
return {
|
|
tools: [
|
|
{
|
|
name: 'structured-tool',
|
|
description: 'A tool with output schema',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {}
|
|
},
|
|
outputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
value: { type: 'number' }
|
|
},
|
|
required: ['value']
|
|
}
|
|
}
|
|
]
|
|
};
|
|
});
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async () => {
|
|
return {
|
|
content: [{ type: 'text', text: 'Result' }],
|
|
structuredContent: { value: 42 }
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {}
|
|
}
|
|
);
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// List tools to cache the output schema
|
|
await client.listTools();
|
|
|
|
// Test callToolStream with valid structured output
|
|
const stream = client.experimental.tasks.callToolStream({ name: 'structured-tool', arguments: {} });
|
|
|
|
const messages = [];
|
|
for await (const message of stream) {
|
|
messages.push(message);
|
|
}
|
|
|
|
// Should have received result with validated structured content
|
|
expect(messages.length).toBe(1);
|
|
expect(messages[0].type).toBe('result');
|
|
if (messages[0].type === 'result') {
|
|
expect(messages[0].result.structuredContent).toEqual({ value: 42 });
|
|
}
|
|
|
|
await client.close();
|
|
await server.close();
|
|
});
|
|
|
|
test('callToolStream() should yield error when structuredContent does not match schema', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: [
|
|
{
|
|
name: 'test-tool',
|
|
description: 'A test tool',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {}
|
|
},
|
|
outputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
result: { type: 'string' },
|
|
count: { type: 'number' }
|
|
},
|
|
required: ['result', 'count'],
|
|
additionalProperties: false
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async () => {
|
|
// Return invalid structured content (count is string instead of number)
|
|
return {
|
|
structuredContent: { result: 'success', count: 'not a number' }
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {}
|
|
}
|
|
);
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
// List tools to cache the schemas
|
|
await client.listTools();
|
|
|
|
const stream = client.experimental.tasks.callToolStream({ name: 'test-tool', arguments: {} });
|
|
|
|
const messages = [];
|
|
for await (const message of stream) {
|
|
messages.push(message);
|
|
}
|
|
|
|
expect(messages.length).toBe(1);
|
|
expect(messages[0].type).toBe('error');
|
|
if (messages[0].type === 'error') {
|
|
expect(messages[0].error.message).toMatch(/Structured content does not match the tool's output schema/);
|
|
}
|
|
|
|
await client.close();
|
|
await server.close();
|
|
});
|
|
|
|
test('callToolStream() should yield error when tool with outputSchema returns no structuredContent', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: [
|
|
{
|
|
name: 'test-tool',
|
|
description: 'A test tool',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {}
|
|
},
|
|
outputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
result: { type: 'string' }
|
|
},
|
|
required: ['result']
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async () => {
|
|
return {
|
|
content: [{ type: 'text', text: 'This should be structured content' }]
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {}
|
|
}
|
|
);
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
await client.listTools();
|
|
|
|
const stream = client.experimental.tasks.callToolStream({ name: 'test-tool', arguments: {} });
|
|
|
|
const messages = [];
|
|
for await (const message of stream) {
|
|
messages.push(message);
|
|
}
|
|
|
|
expect(messages.length).toBe(1);
|
|
expect(messages[0].type).toBe('error');
|
|
if (messages[0].type === 'error') {
|
|
expect(messages[0].error.message).toMatch(/Tool test-tool has an output schema but did not return structured content/);
|
|
}
|
|
|
|
await client.close();
|
|
await server.close();
|
|
});
|
|
|
|
test('callToolStream() should handle tools without outputSchema normally', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: [
|
|
{
|
|
name: 'test-tool',
|
|
description: 'A test tool',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {}
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async () => {
|
|
return {
|
|
content: [{ type: 'text', text: 'Normal response' }]
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {}
|
|
}
|
|
);
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
await client.listTools();
|
|
|
|
const stream = client.experimental.tasks.callToolStream({ name: 'test-tool', arguments: {} });
|
|
|
|
const messages = [];
|
|
for await (const message of stream) {
|
|
messages.push(message);
|
|
}
|
|
|
|
expect(messages.length).toBe(1);
|
|
expect(messages[0].type).toBe('result');
|
|
if (messages[0].type === 'result') {
|
|
expect(messages[0].result.content).toEqual([{ type: 'text', text: 'Normal response' }]);
|
|
}
|
|
|
|
await client.close();
|
|
await server.close();
|
|
});
|
|
|
|
test('callToolStream() should handle complex JSON schema validation', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: [
|
|
{
|
|
name: 'complex-tool',
|
|
description: 'A tool with complex schema',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {}
|
|
},
|
|
outputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
name: { type: 'string', minLength: 3 },
|
|
age: { type: 'integer', minimum: 0, maximum: 120 },
|
|
active: { type: 'boolean' },
|
|
tags: {
|
|
type: 'array',
|
|
items: { type: 'string' },
|
|
minItems: 1
|
|
},
|
|
metadata: {
|
|
type: 'object',
|
|
properties: {
|
|
created: { type: 'string' }
|
|
},
|
|
required: ['created']
|
|
}
|
|
},
|
|
required: ['name', 'age', 'active', 'tags', 'metadata'],
|
|
additionalProperties: false
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async () => {
|
|
return {
|
|
structuredContent: {
|
|
name: 'John Doe',
|
|
age: 30,
|
|
active: true,
|
|
tags: ['user', 'admin'],
|
|
metadata: {
|
|
created: '2023-01-01T00:00:00Z'
|
|
}
|
|
}
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {}
|
|
}
|
|
);
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
await client.listTools();
|
|
|
|
const stream = client.experimental.tasks.callToolStream({ name: 'complex-tool', arguments: {} });
|
|
|
|
const messages = [];
|
|
for await (const message of stream) {
|
|
messages.push(message);
|
|
}
|
|
|
|
expect(messages.length).toBe(1);
|
|
expect(messages[0].type).toBe('result');
|
|
if (messages[0].type === 'result') {
|
|
expect(messages[0].result.structuredContent).toBeDefined();
|
|
const structuredContent = messages[0].result.structuredContent as { name: string; age: number };
|
|
expect(structuredContent.name).toBe('John Doe');
|
|
expect(structuredContent.age).toBe(30);
|
|
}
|
|
|
|
await client.close();
|
|
await server.close();
|
|
});
|
|
|
|
test('callToolStream() should yield error with additional properties when not allowed', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: [
|
|
{
|
|
name: 'strict-tool',
|
|
description: 'A tool with strict schema',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {}
|
|
},
|
|
outputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
name: { type: 'string' }
|
|
},
|
|
required: ['name'],
|
|
additionalProperties: false
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async () => {
|
|
return {
|
|
structuredContent: {
|
|
name: 'John',
|
|
extraField: 'not allowed'
|
|
}
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {}
|
|
}
|
|
);
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
await client.listTools();
|
|
|
|
const stream = client.experimental.tasks.callToolStream({ name: 'strict-tool', arguments: {} });
|
|
|
|
const messages = [];
|
|
for await (const message of stream) {
|
|
messages.push(message);
|
|
}
|
|
|
|
expect(messages.length).toBe(1);
|
|
expect(messages[0].type).toBe('error');
|
|
if (messages[0].type === 'error') {
|
|
expect(messages[0].error.message).toMatch(/Structured content does not match the tool's output schema/);
|
|
}
|
|
|
|
await client.close();
|
|
await server.close();
|
|
});
|
|
|
|
test('callToolStream() should not validate structuredContent when isError is true', async () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: [
|
|
{
|
|
name: 'test-tool',
|
|
description: 'A test tool',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {}
|
|
},
|
|
outputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
result: { type: 'string' }
|
|
},
|
|
required: ['result']
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async () => {
|
|
// Return isError with content (no structuredContent) - should NOT trigger validation error
|
|
return {
|
|
isError: true,
|
|
content: [{ type: 'text', text: 'Something went wrong' }]
|
|
};
|
|
});
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
const client = new Client(
|
|
{
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {}
|
|
}
|
|
);
|
|
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
await client.listTools();
|
|
|
|
const stream = client.experimental.tasks.callToolStream({ name: 'test-tool', arguments: {} });
|
|
|
|
const messages = [];
|
|
for await (const message of stream) {
|
|
messages.push(message);
|
|
}
|
|
|
|
// Should have received result (not error), with isError flag set
|
|
expect(messages.length).toBe(1);
|
|
expect(messages[0].type).toBe('result');
|
|
if (messages[0].type === 'result') {
|
|
expect(messages[0].result.isError).toBe(true);
|
|
expect(messages[0].result.content).toEqual([{ type: 'text', text: 'Something went wrong' }]);
|
|
}
|
|
|
|
await client.close();
|
|
await server.close();
|
|
});
|
|
|
|
describe('getSupportedElicitationModes', () => {
|
|
test('should support nothing when capabilities are undefined', () => {
|
|
const result = getSupportedElicitationModes(undefined);
|
|
expect(result.supportsFormMode).toBe(false);
|
|
expect(result.supportsUrlMode).toBe(false);
|
|
});
|
|
|
|
test('should default to form mode when capabilities are an empty object', () => {
|
|
const result = getSupportedElicitationModes({});
|
|
expect(result.supportsFormMode).toBe(true);
|
|
expect(result.supportsUrlMode).toBe(false);
|
|
});
|
|
|
|
test('should support form mode when form is explicitly declared', () => {
|
|
const result = getSupportedElicitationModes({ form: {} });
|
|
expect(result.supportsFormMode).toBe(true);
|
|
expect(result.supportsUrlMode).toBe(false);
|
|
});
|
|
|
|
test('should support url mode when url is explicitly declared', () => {
|
|
const result = getSupportedElicitationModes({ url: {} });
|
|
expect(result.supportsFormMode).toBe(false);
|
|
expect(result.supportsUrlMode).toBe(true);
|
|
});
|
|
|
|
test('should support both modes when both are explicitly declared', () => {
|
|
const result = getSupportedElicitationModes({ form: {}, url: {} });
|
|
expect(result.supportsFormMode).toBe(true);
|
|
expect(result.supportsUrlMode).toBe(true);
|
|
});
|
|
|
|
test('should support form mode when form declares applyDefaults', () => {
|
|
const result = getSupportedElicitationModes({ form: { applyDefaults: true } });
|
|
expect(result.supportsFormMode).toBe(true);
|
|
expect(result.supportsUrlMode).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Client sampling validation with tools', () => {
|
|
test('should validate array content with tool_use when request includes tools', async () => {
|
|
const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} });
|
|
|
|
const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } });
|
|
|
|
// Handler returns array content with tool_use - should validate with CreateMessageResultWithToolsSchema
|
|
client.setRequestHandler(CreateMessageRequestSchema, async () => ({
|
|
model: 'test-model',
|
|
role: 'assistant',
|
|
stopReason: 'toolUse',
|
|
content: [{ type: 'tool_use', id: 'call_1', name: 'test_tool', input: { arg: 'value' } }]
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
const result = await server.createMessage({
|
|
messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }],
|
|
maxTokens: 100,
|
|
tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }]
|
|
});
|
|
|
|
expect(result.stopReason).toBe('toolUse');
|
|
expect(Array.isArray(result.content)).toBe(true);
|
|
expect((result.content as Array<{ type: string }>)[0].type).toBe('tool_use');
|
|
});
|
|
|
|
test('should validate single content when request includes tools', async () => {
|
|
const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} });
|
|
|
|
const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } });
|
|
|
|
// Handler returns single content (text) - should still validate with CreateMessageResultWithToolsSchema
|
|
client.setRequestHandler(CreateMessageRequestSchema, async () => ({
|
|
model: 'test-model',
|
|
role: 'assistant',
|
|
content: { type: 'text', text: 'No tool needed' }
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
const result = await server.createMessage({
|
|
messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }],
|
|
maxTokens: 100,
|
|
tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }]
|
|
});
|
|
|
|
expect((result.content as { type: string }).type).toBe('text');
|
|
});
|
|
|
|
test('should validate single content when request has no tools', async () => {
|
|
const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} });
|
|
|
|
const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } });
|
|
|
|
// Handler returns single content - should validate with CreateMessageResultSchema
|
|
client.setRequestHandler(CreateMessageRequestSchema, async () => ({
|
|
model: 'test-model',
|
|
role: 'assistant',
|
|
content: { type: 'text', text: 'Response' }
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
const result = await server.createMessage({
|
|
messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }],
|
|
maxTokens: 100
|
|
});
|
|
|
|
expect((result.content as { type: string }).type).toBe('text');
|
|
});
|
|
|
|
test('should reject array content when request has no tools', async () => {
|
|
const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} });
|
|
|
|
const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } });
|
|
|
|
// Handler returns array content - should fail validation with CreateMessageResultSchema
|
|
client.setRequestHandler(CreateMessageRequestSchema, async () => ({
|
|
model: 'test-model',
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'Array response' }]
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
await expect(
|
|
server.createMessage({
|
|
messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }],
|
|
maxTokens: 100
|
|
})
|
|
).rejects.toThrow('Invalid sampling result');
|
|
});
|
|
|
|
test('should validate array content when request includes toolChoice', async () => {
|
|
const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} });
|
|
|
|
const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } });
|
|
|
|
// Handler returns array content with tool_use
|
|
client.setRequestHandler(CreateMessageRequestSchema, async () => ({
|
|
model: 'test-model',
|
|
role: 'assistant',
|
|
stopReason: 'toolUse',
|
|
content: [{ type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} }]
|
|
}));
|
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
|
|
const result = await server.createMessage({
|
|
messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }],
|
|
maxTokens: 100,
|
|
tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }],
|
|
toolChoice: { mode: 'auto' }
|
|
});
|
|
|
|
expect(result.stopReason).toBe('toolUse');
|
|
expect(Array.isArray(result.content)).toBe(true);
|
|
});
|
|
});
|