feat: pluggable JSON schema validator providers (#1012)
This commit is contained in:
parent
7098bff073
commit
5a8fb39430
4 changed files with 2373 additions and 134 deletions
642
src/server/elicitation.test.ts
Normal file
642
src/server/elicitation.test.ts
Normal file
|
|
@ -0,0 +1,642 @@
|
|||
/**
|
||||
* Comprehensive elicitation flow tests with validator integration
|
||||
*
|
||||
* These tests verify the end-to-end elicitation flow from server requesting
|
||||
* input to client responding and validation of the response against schemas.
|
||||
*
|
||||
* Per the MCP spec, elicitation only supports object schemas, not primitives.
|
||||
*/
|
||||
|
||||
import { Client } from '../client/index.js';
|
||||
import { InMemoryTransport } from '../inMemory.js';
|
||||
import { ElicitRequestSchema } from '../types.js';
|
||||
import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js';
|
||||
import { CfWorkerJsonSchemaValidator } from '../validation/cfworker-provider.js';
|
||||
import { Server } from './index.js';
|
||||
|
||||
const ajvProvider = new AjvJsonSchemaValidator();
|
||||
const cfWorkerProvider = new CfWorkerJsonSchemaValidator();
|
||||
|
||||
describe('Elicitation Flow', () => {
|
||||
describe('with AJV validator', () => {
|
||||
testElicitationFlow(ajvProvider, 'AJV');
|
||||
});
|
||||
|
||||
describe('with CfWorker validator', () => {
|
||||
testElicitationFlow(cfWorkerProvider, 'CfWorker');
|
||||
});
|
||||
});
|
||||
|
||||
function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWorkerProvider, validatorName: string) {
|
||||
test(`${validatorName}: should elicit simple object with string field`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { name: 'John Doe' }
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
message: 'What is your name?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { name: 'John Doe' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should elicit object with integer field`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { age: 42 }
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
message: 'What is your age?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
age: { type: 'integer', minimum: 0, maximum: 150 }
|
||||
},
|
||||
required: ['age']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { age: 42 }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should elicit object with boolean field`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { agree: true }
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
message: 'Do you agree?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
agree: { type: 'boolean' }
|
||||
},
|
||||
required: ['agree']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { agree: true }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should elicit complex object with multiple fields`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
const userData = {
|
||||
name: 'Jane Smith',
|
||||
email: 'jane@example.com',
|
||||
age: 28,
|
||||
street: '123 Main St',
|
||||
city: 'San Francisco',
|
||||
zipCode: '94105',
|
||||
newsletter: true,
|
||||
notifications: false
|
||||
};
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: userData
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 },
|
||||
email: { type: 'string', format: 'email' },
|
||||
age: { type: 'integer', minimum: 0, maximum: 150 },
|
||||
street: { type: 'string' },
|
||||
city: { type: 'string' },
|
||||
zipCode: { type: 'string', pattern: '^[0-9]{5}$' },
|
||||
newsletter: { type: 'boolean' },
|
||||
notifications: { type: 'boolean' }
|
||||
},
|
||||
required: ['name', 'email', 'age', 'street', 'city', 'zipCode']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: userData
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid object (missing required field)`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
email: 'user@example.com'
|
||||
// Missing required 'name' field
|
||||
}
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
email: { type: 'string' }
|
||||
},
|
||||
required: ['name', 'email']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid field type`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
name: 'John Doe',
|
||||
age: 'thirty' // Wrong type - should be integer
|
||||
}
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
age: { type: 'integer' }
|
||||
},
|
||||
required: ['name', 'age']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid string (too short)`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { name: '' } // Too short
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'What is your name?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid integer (out of range)`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { age: 200 } // Too high
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'What is your age?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
age: { type: 'integer', minimum: 0, maximum: 150 }
|
||||
},
|
||||
required: ['age']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid pattern`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { zipCode: 'ABC123' } // Doesn't match pattern
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'Enter a 5-digit zip code',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
zipCode: { type: 'string', pattern: '^[0-9]{5}$' }
|
||||
},
|
||||
required: ['zipCode']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should allow decline action without validation`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'decline'
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'decline'
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should allow cancel action without validation`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'cancel'
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'cancel'
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should handle multiple sequential elicitation requests`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
let requestCount = 0;
|
||||
client.setRequestHandler(ElicitRequestSchema, request => {
|
||||
requestCount++;
|
||||
if (request.params.message.includes('name')) {
|
||||
return { action: 'accept', content: { name: 'Alice' } };
|
||||
} else if (request.params.message.includes('age')) {
|
||||
return { action: 'accept', content: { age: 30 } };
|
||||
} else if (request.params.message.includes('city')) {
|
||||
return { action: 'accept', content: { city: 'New York' } };
|
||||
}
|
||||
return { action: 'decline' };
|
||||
});
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const nameResult = await server.elicitInput({
|
||||
message: 'What is your name?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string', minLength: 1 } },
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
const ageResult = await server.elicitInput({
|
||||
message: 'What is your age?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: { age: { type: 'integer', minimum: 0 } },
|
||||
required: ['age']
|
||||
}
|
||||
});
|
||||
|
||||
const cityResult = await server.elicitInput({
|
||||
message: 'What is your city?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: { city: { type: 'string', minLength: 1 } },
|
||||
required: ['city']
|
||||
}
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(3);
|
||||
expect(nameResult).toEqual({
|
||||
action: 'accept',
|
||||
content: { name: 'Alice' }
|
||||
});
|
||||
expect(ageResult).toEqual({ action: 'accept', content: { age: 30 } });
|
||||
expect(cityResult).toEqual({
|
||||
action: 'accept',
|
||||
content: { city: 'New York' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should validate with optional fields present`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { name: 'John', nickname: 'Johnny' }
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
message: 'Enter your name',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 },
|
||||
nickname: { type: 'string' }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { name: 'John', nickname: 'Johnny' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should validate with optional fields absent`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { name: 'John' }
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
message: 'Enter your name',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 },
|
||||
nickname: { type: 'string' }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { name: 'John' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should validate email format`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { email: 'user@example.com' }
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
message: 'Enter your email',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' }
|
||||
},
|
||||
required: ['email']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { email: 'user@example.com' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid email format`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { email: 'not-an-email' }
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'Enter your email',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' }
|
||||
},
|
||||
required: ['email']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,26 +1,24 @@
|
|||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable no-constant-binary-expression */
|
||||
/* eslint-disable @typescript-eslint/no-unused-expressions */
|
||||
import { Server } from './index.js';
|
||||
import { z } from 'zod';
|
||||
import { Client } from '../client/index.js';
|
||||
import { InMemoryTransport } from '../inMemory.js';
|
||||
import type { Transport } from '../shared/transport.js';
|
||||
import {
|
||||
RequestSchema,
|
||||
NotificationSchema,
|
||||
ResultSchema,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
SUPPORTED_PROTOCOL_VERSIONS,
|
||||
CreateMessageRequestSchema,
|
||||
ElicitRequestSchema,
|
||||
ErrorCode,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
ListPromptsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
type LoggingMessageNotification,
|
||||
NotificationSchema,
|
||||
RequestSchema,
|
||||
ResultSchema,
|
||||
SetLevelRequestSchema,
|
||||
ErrorCode,
|
||||
LoggingMessageNotification
|
||||
SUPPORTED_PROTOCOL_VERSIONS
|
||||
} from '../types.js';
|
||||
import { Transport } from '../shared/transport.js';
|
||||
import { InMemoryTransport } from '../inMemory.js';
|
||||
import { Client } from '../client/index.js';
|
||||
import { Server } from './index.js';
|
||||
|
||||
test('should accept latest protocol version', async () => {
|
||||
let sendPromiseResolve: (value: unknown) => void;
|
||||
|
|
@ -235,7 +233,7 @@ test('should respect client capabilities', async () => {
|
|||
);
|
||||
|
||||
// Implement request handler for sampling/createMessage
|
||||
client.setRequestHandler(CreateMessageRequestSchema, async request => {
|
||||
client.setRequestHandler(CreateMessageRequestSchema, async _request => {
|
||||
// Mock implementation of createMessage
|
||||
return {
|
||||
model: 'test-model',
|
||||
|
|
@ -377,7 +375,7 @@ test('should validate elicitation response against requested schema', async () =
|
|||
);
|
||||
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, request => ({
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
name: 'John Doe',
|
||||
|
|
@ -454,7 +452,7 @@ test('should reject elicitation response with invalid data', async () => {
|
|||
);
|
||||
|
||||
// Set up client to return invalid response (missing required field, invalid age)
|
||||
client.setRequestHandler(ElicitRequestSchema, request => ({
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
email: '', // Invalid - too short
|
||||
|
|
@ -523,7 +521,7 @@ test('should allow elicitation reject and cancel without validation', async () =
|
|||
);
|
||||
|
||||
let requestCount = 0;
|
||||
client.setRequestHandler(ElicitRequestSchema, request => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => {
|
||||
requestCount++;
|
||||
if (requestCount === 1) {
|
||||
return { action: 'decline' };
|
||||
|
|
@ -579,7 +577,7 @@ test('should respect server notification capabilities', async () => {
|
|||
}
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const [_clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await server.connect(serverTransport);
|
||||
|
||||
|
|
@ -685,7 +683,7 @@ test('should typecheck', () => {
|
|||
);
|
||||
|
||||
// Typecheck that only valid weather requests/notifications/results are allowed
|
||||
weatherServer.setRequestHandler(GetWeatherRequestSchema, request => {
|
||||
weatherServer.setRequestHandler(GetWeatherRequestSchema, _request => {
|
||||
return {
|
||||
temperature: 72,
|
||||
conditions: 'sunny'
|
||||
|
|
@ -723,7 +721,7 @@ test('should handle server cancelling a request', async () => {
|
|||
);
|
||||
|
||||
// Set up client to delay responding to createMessage
|
||||
client.setRequestHandler(CreateMessageRequestSchema, async (_request, extra) => {
|
||||
client.setRequestHandler(CreateMessageRequestSchema, async (_request, _extra) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
return {
|
||||
model: 'test',
|
||||
|
|
|
|||
|
|
@ -1,37 +1,38 @@
|
|||
import { mergeCapabilities, Protocol, ProtocolOptions, RequestOptions } from '../shared/protocol.js';
|
||||
import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js';
|
||||
import {
|
||||
ClientCapabilities,
|
||||
CreateMessageRequest,
|
||||
type ClientCapabilities,
|
||||
type CreateMessageRequest,
|
||||
CreateMessageResultSchema,
|
||||
ElicitRequest,
|
||||
ElicitResult,
|
||||
type ElicitRequest,
|
||||
type ElicitResult,
|
||||
ElicitResultSchema,
|
||||
EmptyResultSchema,
|
||||
Implementation,
|
||||
InitializedNotificationSchema,
|
||||
InitializeRequest,
|
||||
InitializeRequestSchema,
|
||||
InitializeResult,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
ListRootsRequest,
|
||||
ListRootsResultSchema,
|
||||
LoggingMessageNotification,
|
||||
McpError,
|
||||
ErrorCode,
|
||||
Notification,
|
||||
Request,
|
||||
ResourceUpdatedNotification,
|
||||
Result,
|
||||
ServerCapabilities,
|
||||
ServerNotification,
|
||||
ServerRequest,
|
||||
ServerResult,
|
||||
SUPPORTED_PROTOCOL_VERSIONS,
|
||||
LoggingLevel,
|
||||
type Implementation,
|
||||
InitializedNotificationSchema,
|
||||
type InitializeRequest,
|
||||
InitializeRequestSchema,
|
||||
type InitializeResult,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
type ListRootsRequest,
|
||||
ListRootsResultSchema,
|
||||
type LoggingLevel,
|
||||
LoggingLevelSchema,
|
||||
type LoggingMessageNotification,
|
||||
McpError,
|
||||
type Notification,
|
||||
type Request,
|
||||
type ResourceUpdatedNotification,
|
||||
type Result,
|
||||
type ServerCapabilities,
|
||||
type ServerNotification,
|
||||
type ServerRequest,
|
||||
type ServerResult,
|
||||
SetLevelRequestSchema,
|
||||
LoggingLevelSchema
|
||||
SUPPORTED_PROTOCOL_VERSIONS
|
||||
} from '../types.js';
|
||||
import Ajv from 'ajv';
|
||||
import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js';
|
||||
import type { JsonSchemaType, jsonSchemaValidator } from '../validation/types.js';
|
||||
|
||||
export type ServerOptions = ProtocolOptions & {
|
||||
/**
|
||||
|
|
@ -43,6 +44,37 @@ export type ServerOptions = ProtocolOptions & {
|
|||
* Optional instructions describing how to use the server and its features.
|
||||
*/
|
||||
instructions?: string;
|
||||
|
||||
/**
|
||||
* JSON Schema validator for elicitation response validation.
|
||||
*
|
||||
* The validator is used to validate user input returned from elicitation
|
||||
* requests against the requested schema.
|
||||
*
|
||||
* @default AjvJsonSchemaValidator
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // ajv (default)
|
||||
* const server = new Server(
|
||||
* { name: 'my-server', version: '1.0.0' },
|
||||
* {
|
||||
* capabilities: {}
|
||||
* jsonSchemaValidator: new AjvJsonSchemaValidator()
|
||||
* }
|
||||
* );
|
||||
*
|
||||
* // @cfworker/json-schema
|
||||
* const server = new Server(
|
||||
* { name: 'my-server', version: '1.0.0' },
|
||||
* {
|
||||
* capabilities: {},
|
||||
* jsonSchemaValidator: new CfWorkerJsonSchemaValidator()
|
||||
* }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
jsonSchemaValidator?: jsonSchemaValidator;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -79,6 +111,7 @@ export class Server<
|
|||
private _clientVersion?: Implementation;
|
||||
private _capabilities: ServerCapabilities;
|
||||
private _instructions?: string;
|
||||
private _jsonSchemaValidator: jsonSchemaValidator;
|
||||
|
||||
/**
|
||||
* Callback for when initialization has fully completed (i.e., the client has sent an `initialized` notification).
|
||||
|
|
@ -95,6 +128,7 @@ export class Server<
|
|||
super(options);
|
||||
this._capabilities = options?.capabilities ?? {};
|
||||
this._instructions = options?.instructions;
|
||||
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator();
|
||||
|
||||
this.setRequestHandler(InitializeRequestSchema, request => this._oninitialize(request));
|
||||
this.setNotificationHandler(InitializedNotificationSchema, () => this.oninitialized?.());
|
||||
|
|
@ -289,24 +323,25 @@ export class Server<
|
|||
const result = await this.request({ method: 'elicitation/create', params }, ElicitResultSchema, options);
|
||||
|
||||
// Validate the response content against the requested schema if action is "accept"
|
||||
if (result.action === 'accept' && result.content) {
|
||||
if (result.action === 'accept' && result.content && params.requestedSchema) {
|
||||
try {
|
||||
const ajv = new Ajv();
|
||||
const validator = this._jsonSchemaValidator.getValidator(params.requestedSchema as JsonSchemaType);
|
||||
const validationResult = validator(result.content);
|
||||
|
||||
const validate = ajv.compile(params.requestedSchema);
|
||||
const isValid = validate(result.content);
|
||||
|
||||
if (!isValid) {
|
||||
if (!validationResult.valid) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`Elicitation response content does not match requested schema: ${ajv.errorsText(validate.errors)}`
|
||||
`Elicitation response content does not match requested schema: ${validationResult.errorMessage}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof McpError) {
|
||||
throw error;
|
||||
}
|
||||
throw new McpError(ErrorCode.InternalError, `Error validating elicitation response: ${error}`);
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
`Error validating elicitation response: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,24 @@
|
|||
import { McpServer } from './mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { Client } from '../client/index.js';
|
||||
import { InMemoryTransport } from '../inMemory.js';
|
||||
import { z } from 'zod';
|
||||
import { getDisplayName } from '../shared/metadataUtils.js';
|
||||
import { UriTemplate } from '../shared/uriTemplate.js';
|
||||
import {
|
||||
ListToolsResultSchema,
|
||||
CallToolResultSchema,
|
||||
CompleteResultSchema,
|
||||
ElicitRequestSchema,
|
||||
GetPromptResultSchema,
|
||||
ListPromptsResultSchema,
|
||||
ListResourcesResultSchema,
|
||||
ListResourceTemplatesResultSchema,
|
||||
ReadResourceResultSchema,
|
||||
ListPromptsResultSchema,
|
||||
GetPromptResultSchema,
|
||||
CompleteResultSchema,
|
||||
ListToolsResultSchema,
|
||||
LoggingMessageNotificationSchema,
|
||||
Notification,
|
||||
TextContent,
|
||||
ElicitRequestSchema
|
||||
type Notification,
|
||||
ReadResourceResultSchema,
|
||||
type TextContent
|
||||
} from '../types.js';
|
||||
import { ResourceTemplate } from './mcp.js';
|
||||
import { completable } from './completable.js';
|
||||
import { UriTemplate } from '../shared/uriTemplate.js';
|
||||
import { getDisplayName } from '../shared/metadataUtils.js';
|
||||
import { McpServer, ResourceTemplate } from './mcp.js';
|
||||
|
||||
describe('McpServer', () => {
|
||||
/***
|
||||
|
|
@ -112,11 +111,22 @@ describe('McpServer', () => {
|
|||
}
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text' as const, text: `Operation completed with ${steps} steps` }] };
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Operation completed with ${steps} steps`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const progressUpdates: Array<{ progress: number; total?: number; message?: string }> = [];
|
||||
const progressUpdates: Array<{
|
||||
progress: number;
|
||||
total?: number;
|
||||
message?: string;
|
||||
}> = [];
|
||||
|
||||
const client = new Client({
|
||||
name: 'test client',
|
||||
|
|
@ -677,9 +687,15 @@ describe('tool()', () => {
|
|||
|
||||
expect(result.tools).toHaveLength(2);
|
||||
expect(result.tools[0].name).toBe('test');
|
||||
expect(result.tools[0].annotations).toEqual({ title: 'Test Tool', readOnlyHint: true });
|
||||
expect(result.tools[0].annotations).toEqual({
|
||||
title: 'Test Tool',
|
||||
readOnlyHint: true
|
||||
});
|
||||
expect(result.tools[1].name).toBe('test (new api)');
|
||||
expect(result.tools[1].annotations).toEqual({ title: 'Test Tool', readOnlyHint: true });
|
||||
expect(result.tools[1].annotations).toEqual({
|
||||
title: 'Test Tool',
|
||||
readOnlyHint: true
|
||||
});
|
||||
});
|
||||
|
||||
/***
|
||||
|
|
@ -722,7 +738,10 @@ describe('tool()', () => {
|
|||
type: 'object',
|
||||
properties: { name: { type: 'string' } }
|
||||
});
|
||||
expect(result.tools[0].annotations).toEqual({ title: 'Test Tool', readOnlyHint: true });
|
||||
expect(result.tools[0].annotations).toEqual({
|
||||
title: 'Test Tool',
|
||||
readOnlyHint: true
|
||||
});
|
||||
expect(result.tools[1].name).toBe('test (new api)');
|
||||
expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema);
|
||||
expect(result.tools[1].annotations).toEqual(result.tools[0].annotations);
|
||||
|
|
@ -756,7 +775,11 @@ describe('tool()', () => {
|
|||
{
|
||||
description: 'A tool with everything',
|
||||
inputSchema: { name: z.string() },
|
||||
annotations: { title: 'Complete Test Tool', readOnlyHint: true, openWorldHint: false }
|
||||
annotations: {
|
||||
title: 'Complete Test Tool',
|
||||
readOnlyHint: true,
|
||||
openWorldHint: false
|
||||
}
|
||||
},
|
||||
async ({ name }) => ({
|
||||
content: [{ type: 'text', text: `Hello, ${name}!` }]
|
||||
|
|
@ -804,7 +827,11 @@ describe('tool()', () => {
|
|||
'test',
|
||||
'A tool with everything but empty params',
|
||||
{},
|
||||
{ title: 'Complete Test Tool with empty params', readOnlyHint: true, openWorldHint: false },
|
||||
{
|
||||
title: 'Complete Test Tool with empty params',
|
||||
readOnlyHint: true,
|
||||
openWorldHint: false
|
||||
},
|
||||
async () => ({
|
||||
content: [{ type: 'text', text: 'Test response' }]
|
||||
})
|
||||
|
|
@ -815,7 +842,11 @@ describe('tool()', () => {
|
|||
{
|
||||
description: 'A tool with everything but empty params',
|
||||
inputSchema: {},
|
||||
annotations: { title: 'Complete Test Tool with empty params', readOnlyHint: true, openWorldHint: false }
|
||||
annotations: {
|
||||
title: 'Complete Test Tool with empty params',
|
||||
readOnlyHint: true,
|
||||
openWorldHint: false
|
||||
}
|
||||
},
|
||||
async () => ({
|
||||
content: [{ type: 'text' as const, text: 'Test response' }]
|
||||
|
|
@ -1342,7 +1373,7 @@ describe('tool()', () => {
|
|||
|
||||
expect(receivedRequestId).toBeDefined();
|
||||
expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true);
|
||||
expect(result.content && result.content[0].text).toContain('Received request ID:');
|
||||
expect(result.content?.[0].text).toContain('Received request ID:');
|
||||
});
|
||||
|
||||
/***
|
||||
|
|
@ -1370,7 +1401,10 @@ describe('tool()', () => {
|
|||
});
|
||||
|
||||
mcpServer.tool('test-tool', async ({ sendNotification }) => {
|
||||
await sendNotification({ method: 'notifications/message', params: { level: 'debug', data: loggingMessage } });
|
||||
await sendNotification({
|
||||
method: 'notifications/message',
|
||||
params: { level: 'debug', data: loggingMessage }
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue