feat: pluggable JSON schema validator providers (#1012)

This commit is contained in:
Matt 2025-10-25 16:56:42 +01:00 • committed by GitHub
commit 5a8fb39430
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 2373 additions and 134 deletions

View file

@ -1,51 +1,82 @@
import { mergeCapabilities, Protocol, ProtocolOptions, RequestOptions } from '../shared/protocol.js';
import { Transport } from '../shared/transport.js';
import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js';
import type { Transport } from '../shared/transport.js';
import {
CallToolRequest,
type CallToolRequest,
CallToolResultSchema,
ClientCapabilities,
ClientNotification,
ClientRequest,
ClientResult,
CompatibilityCallToolResultSchema,
CompleteRequest,
type ClientCapabilities,
type ClientNotification,
type ClientRequest,
type ClientResult,
type CompatibilityCallToolResultSchema,
type CompleteRequest,
CompleteResultSchema,
EmptyResultSchema,
GetPromptRequest,
ErrorCode,
type GetPromptRequest,
GetPromptResultSchema,
Implementation,
type Implementation,
InitializeResultSchema,
LATEST_PROTOCOL_VERSION,
ListPromptsRequest,
type ListPromptsRequest,
ListPromptsResultSchema,
ListResourcesRequest,
type ListResourcesRequest,
ListResourcesResultSchema,
ListResourceTemplatesRequest,
type ListResourceTemplatesRequest,
ListResourceTemplatesResultSchema,
ListToolsRequest,
type ListToolsRequest,
ListToolsResultSchema,
LoggingLevel,
Notification,
ReadResourceRequest,
type LoggingLevel,
McpError,
type Notification,
type ReadResourceRequest,
ReadResourceResultSchema,
Request,
Result,
ServerCapabilities,
SubscribeRequest,
type Request,
type Result,
type ServerCapabilities,
SUPPORTED_PROTOCOL_VERSIONS,
UnsubscribeRequest,
Tool,
ErrorCode,
McpError
type SubscribeRequest,
type Tool,
type UnsubscribeRequest
} from '../types.js';
import Ajv from 'ajv';
import type { ValidateFunction } from 'ajv';
import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js';
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js';
export type ClientOptions = ProtocolOptions & {
/**
* Capabilities to advertise as being supported by this client.
*/
capabilities?: ClientCapabilities;
/**
* JSON Schema validator for tool output validation.
*
* The validator is used to validate structured content returned by tools
* against their declared output schemas.
*
* @default AjvJsonSchemaValidator
*
* @example
* ```typescript
* // ajv
* const client = new Client(
* { name: 'my-client', version: '1.0.0' },
* {
* capabilities: {},
* jsonSchemaValidator: new AjvJsonSchemaValidator()
* }
* );
*
* // @cfworker/json-schema
* const client = new Client(
* { name: 'my-client', version: '1.0.0' },
* {
* capabilities: {},
* jsonSchemaValidator: new CfWorkerJsonSchemaValidator()
* }
* );
* ```
*/
jsonSchemaValidator?: jsonSchemaValidator;
};
/**
@ -82,8 +113,8 @@ export class Client<
private _serverVersion?: Implementation;
private _capabilities: ClientCapabilities;
private _instructions?: string;
private _cachedToolOutputValidators: Map<string, ValidateFunction> = new Map();
private _ajv: InstanceType<typeof Ajv>;
private _jsonSchemaValidator: jsonSchemaValidator;
private _cachedToolOutputValidators: Map<string, JsonSchemaValidator<unknown>> = new Map();
/**
* Initializes this client with the given name and version information.
@ -94,7 +125,7 @@ export class Client<
) {
super(options);
this._capabilities = options?.capabilities ?? {};
this._ajv = new Ajv();
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator();
}
/**
@ -347,13 +378,13 @@ export class Client<
// Only validate structured content if present (not when there's an error)
if (result.structuredContent) {
try {
// Validate the structured content (which is already an object) against the schema
const isValid = validator(result.structuredContent);
// Validate the structured content against the schema
const validationResult = validator(result.structuredContent);
if (!isValid) {
if (!validationResult.valid) {
throw new McpError(
ErrorCode.InvalidParams,
`Structured content does not match the tool's output schema: ${this._ajv.errorsText(validator.errors)}`
`Structured content does not match the tool's output schema: ${validationResult.errorMessage}`
);
}
} catch (error) {
@ -371,23 +402,26 @@ export class Client<
return result;
}
private cacheToolOutputSchemas(tools: Tool[]) {
/**
* Cache validators for tool output schemas.
* Called after listTools() to pre-compile validators for better performance.
*/
private cacheToolOutputSchemas(tools: Tool[]): void {
this._cachedToolOutputValidators.clear();
for (const tool of tools) {
// If the tool has an outputSchema, create and cache the Ajv validator
// If the tool has an outputSchema, create and cache the validator
if (tool.outputSchema) {
try {
const validator = this._ajv.compile(tool.outputSchema);
this._cachedToolOutputValidators.set(tool.name, validator);
} catch {
// Ignore schema compilation errors
}
const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType);
this._cachedToolOutputValidators.set(tool.name, toolValidator);
}
}
}
private getToolOutputValidator(toolName: string): ValidateFunction | undefined {
/**
* Get cached validator for a tool
*/
private getToolOutputValidator(toolName: string): JsonSchemaValidator<unknown> | undefined {
return this._cachedToolOutputValidators.get(toolName);
}

View file

@ -20,7 +20,7 @@ import {
ReadResourceResultSchema
} from '../../types.js';
import { getDisplayName } from '../../shared/metadataUtils.js';
import Ajv from 'ajv';
import { Ajv } from 'ajv';
// Create readline interface for user input
const readline = createInterface({
@ -377,7 +377,7 @@ async function connect(url?: string): Promise<void> {
if (!isValid) {
console.log('❌ Validation errors:');
validate.errors?.forEach(error => {
console.log(` - ${error.dataPath || 'root'}: ${error.message}`);
console.log(` - ${error.instancePath || 'root'}: ${error.message}`);
});
if (attempts < maxAttempts) {

View file

@ -0,0 +1,476 @@
// Run with: npx tsx src/examples/server/elicitationExample.ts
//
// This example demonstrates how to use elicitation to collect structured user input
// with JSON Schema validation via a local HTTP server with SSE streaming.
// Elicitation allows servers to request user input through the client interface
// with schema-based validation.
import { randomUUID } from 'node:crypto';
import cors from 'cors';
import express, { type Request, type Response } from 'express';
import { McpServer } from '../../server/mcp.js';
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
import { isInitializeRequest } from '../../types.js';
// Create MCP server - it will automatically use AjvJsonSchemaValidator with sensible defaults
// The validator supports format validation (email, date, etc.) if ajv-formats is installed
const mcpServer = new McpServer(
{
name: 'elicitation-example-server',
version: '1.0.0'
},
{
capabilities: {}
}
);
/**
* Example 1: Simple user registration tool
* Collects username, email, and password from the user
*/
mcpServer.registerTool(
'register_user',
{
description: 'Register a new user account by collecting their information',
inputSchema: {}
},
async () => {
try {
// Request user information through elicitation
const result = await mcpServer.server.elicitInput({
message: 'Please provide your registration information:',
requestedSchema: {
type: 'object',
properties: {
username: {
type: 'string',
title: 'Username',
description: 'Your desired username (3-20 characters)',
minLength: 3,
maxLength: 20
},
email: {
type: 'string',
title: 'Email',
description: 'Your email address',
format: 'email'
},
password: {
type: 'string',
title: 'Password',
description: 'Your password (min 8 characters)',
minLength: 8
},
newsletter: {
type: 'boolean',
title: 'Newsletter',
description: 'Subscribe to newsletter?',
default: false
}
},
required: ['username', 'email', 'password']
}
});
// Handle the different possible actions
if (result.action === 'accept' && result.content) {
const { username, email, newsletter } = result.content as {
username: string;
email: string;
password: string;
newsletter?: boolean;
};
return {
content: [
{
type: 'text',
text: `Registration successful!\n\nUsername: ${username}\nEmail: ${email}\nNewsletter: ${newsletter ? 'Yes' : 'No'}`
}
]
};
} else if (result.action === 'decline') {
return {
content: [
{
type: 'text',
text: 'Registration cancelled by user.'
}
]
};
} else {
return {
content: [
{
type: 'text',
text: 'Registration was cancelled.'
}
]
};
}
} catch (error) {
return {
content: [
{
type: 'text',
text: `Registration failed: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: true
};
}
}
);
/**
* Example 2: Multi-step workflow with multiple elicitation requests
* Demonstrates how to collect information in multiple steps
*/
mcpServer.registerTool(
'create_event',
{
description: 'Create a calendar event by collecting event details',
inputSchema: {}
},
async () => {
try {
// Step 1: Collect basic event information
const basicInfo = await mcpServer.server.elicitInput({
message: 'Step 1: Enter basic event information',
requestedSchema: {
type: 'object',
properties: {
title: {
type: 'string',
title: 'Event Title',
description: 'Name of the event',
minLength: 1
},
description: {
type: 'string',
title: 'Description',
description: 'Event description (optional)'
}
},
required: ['title']
}
});
if (basicInfo.action !== 'accept' || !basicInfo.content) {
return {
content: [{ type: 'text', text: 'Event creation cancelled.' }]
};
}
// Step 2: Collect date and time
const dateTime = await mcpServer.server.elicitInput({
message: 'Step 2: Enter date and time',
requestedSchema: {
type: 'object',
properties: {
date: {
type: 'string',
title: 'Date',
description: 'Event date',
format: 'date'
},
startTime: {
type: 'string',
title: 'Start Time',
description: 'Event start time (HH:MM)',
pattern: '^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$'
},
duration: {
type: 'integer',
title: 'Duration',
description: 'Duration in minutes',
minimum: 15,
maximum: 480
}
},
required: ['date', 'startTime', 'duration']
}
});
if (dateTime.action !== 'accept' || !dateTime.content) {
return {
content: [{ type: 'text', text: 'Event creation cancelled.' }]
};
}
// Combine all collected information
const event = {
...basicInfo.content,
...dateTime.content
};
return {
content: [
{
type: 'text',
text: `Event created successfully!\n\n${JSON.stringify(event, null, 2)}`
}
]
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Event creation failed: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: true
};
}
}
);
/**
* Example 3: Collecting address information
* Demonstrates validation with patterns and optional fields
*/
mcpServer.registerTool(
'update_shipping_address',
{
description: 'Update shipping address with validation',
inputSchema: {}
},
async () => {
try {
const result = await mcpServer.server.elicitInput({
message: 'Please provide your shipping address:',
requestedSchema: {
type: 'object',
properties: {
name: {
type: 'string',
title: 'Full Name',
description: 'Recipient name',
minLength: 1
},
street: {
type: 'string',
title: 'Street Address',
minLength: 1
},
city: {
type: 'string',
title: 'City',
minLength: 1
},
state: {
type: 'string',
title: 'State/Province',
minLength: 2,
maxLength: 2
},
zipCode: {
type: 'string',
title: 'ZIP/Postal Code',
description: '5-digit ZIP code',
pattern: '^[0-9]{5}$'
},
phone: {
type: 'string',
title: 'Phone Number (optional)',
description: 'Contact phone number'
}
},
required: ['name', 'street', 'city', 'state', 'zipCode']
}
});
if (result.action === 'accept' && result.content) {
return {
content: [
{
type: 'text',
text: `Address updated successfully!\n\n${JSON.stringify(result.content, null, 2)}`
}
]
};
} else if (result.action === 'decline') {
return {
content: [{ type: 'text', text: 'Address update cancelled by user.' }]
};
} else {
return {
content: [{ type: 'text', text: 'Address update was cancelled.' }]
};
}
} catch (error) {
return {
content: [
{
type: 'text',
text: `Address update failed: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: true
};
}
}
);
async function main() {
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
const app = express();
app.use(express.json());
// Allow CORS for all domains, expose the Mcp-Session-Id header
app.use(
cors({
origin: '*',
exposedHeaders: ['Mcp-Session-Id']
})
);
// Map to store transports by session ID
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
// MCP POST endpoint
const mcpPostHandler = async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId) {
console.log(`Received MCP request for session: ${sessionId}`);
}
try {
let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
// Reuse existing transport for this session
transport = transports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
// New initialization request - create new transport
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: sessionId => {
// Store the transport by session ID when session is initialized
console.log(`Session initialized with ID: ${sessionId}`);
transports[sessionId] = transport;
}
});
// Set up onclose handler to clean up transport when closed
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && transports[sid]) {
console.log(`Transport closed for session ${sid}, removing from transports map`);
delete transports[sid];
}
};
// Connect the transport to the MCP server BEFORE handling the request
await mcpServer.connect(transport);
await transport.handleRequest(req, res, req.body);
return;
} else {
// Invalid request - no session ID or not initialization request
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided'
},
id: null
});
return;
}
// Handle the request with existing transport
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('Error handling MCP request:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error'
},
id: null
});
}
}
};
app.post('/mcp', mcpPostHandler);
// Handle GET requests for SSE streams
const mcpGetHandler = async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}
console.log(`Establishing SSE stream for session ${sessionId}`);
const transport = transports[sessionId];
await transport.handleRequest(req, res);
};
app.get('/mcp', mcpGetHandler);
// Handle DELETE requests for session termination
const mcpDeleteHandler = async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}
console.log(`Received session termination request for session ${sessionId}`);
try {
const transport = transports[sessionId];
await transport.handleRequest(req, res);
} catch (error) {
console.error('Error handling session termination:', error);
if (!res.headersSent) {
res.status(500).send('Error processing session termination');
}
}
};
app.delete('/mcp', mcpDeleteHandler);
// Start listening
app.listen(PORT, error => {
if (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
console.log(`Elicitation example server is running on http://localhost:${PORT}/mcp`);
console.log('Available tools:');
console.log(' - register_user: Collect user registration information');
console.log(' - create_event: Multi-step event creation');
console.log(' - update_shipping_address: Collect and validate address');
console.log('\nConnect your MCP client to this server using the HTTP transport.');
});
// Handle server shutdown
process.on('SIGINT', async () => {
console.log('Shutting down server...');
// Close all active transports to properly clean up resources
for (const sessionId in transports) {
try {
console.log(`Closing transport for session ${sessionId}`);
await transports[sessionId].close();
delete transports[sessionId];
} catch (error) {
console.error(`Error closing transport for session ${sessionId}:`, error);
}
}
console.log('Server shutdown complete');
process.exit(0);
});
}
main().catch(error => {
console.error('Server error:', error);
process.exit(1);
});

View 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/);
});
}

View file

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

View file

@ -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)}`
);
}
}

View file

@ -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: [
{

View file

@ -0,0 +1,97 @@
/**
* AJV-based JSON Schema validator provider
*/
import { Ajv } from 'ajv';
import _addFormats from 'ajv-formats';
import type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } from './types.js';
function createDefaultAjvInstance(): Ajv {
const ajv = new Ajv({
strict: false,
validateFormats: true,
validateSchema: false,
allErrors: true
});
const addFormats = _addFormats as unknown as typeof _addFormats.default;
addFormats(ajv);
return ajv;
}
/**
* @example
* ```typescript
* // Use with default AJV instance (recommended)
* import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv';
* const validator = new AjvJsonSchemaValidator();
*
* // Use with custom AJV instance
* import { Ajv } from 'ajv';
* const ajv = new Ajv({ strict: true, allErrors: true });
* const validator = new AjvJsonSchemaValidator(ajv);
* ```
*/
export class AjvJsonSchemaValidator implements jsonSchemaValidator {
private _ajv: Ajv;
/**
* Create an AJV validator
*
* @param ajv - Optional pre-configured AJV instance. If not provided, a default instance will be created.
*
* @example
* ```typescript
* // Use default configuration (recommended for most cases)
* import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv';
* const validator = new AjvJsonSchemaValidator();
*
* // Or provide custom AJV instance for advanced configuration
* import { Ajv } from 'ajv';
* import addFormats from 'ajv-formats';
*
* const ajv = new Ajv({ validateFormats: true });
* addFormats(ajv);
* const validator = new AjvJsonSchemaValidator(ajv);
* ```
*/
constructor(ajv?: Ajv) {
this._ajv = ajv ?? createDefaultAjvInstance();
}
/**
* Create a validator for the given JSON Schema
*
* The validator is compiled once and can be reused multiple times.
* If the schema has an $id, it will be cached by AJV automatically.
*
* @param schema - Standard JSON Schema object
* @returns A validator function that validates input data
*/
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
// Check if schema has $id and is already compiled/cached
const ajvValidator =
'$id' in schema && typeof schema.$id === 'string'
? (this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema))
: this._ajv.compile(schema);
return (input: unknown): JsonSchemaValidatorResult<T> => {
const valid = ajvValidator(input);
if (valid) {
return {
valid: true,
data: input as T,
errorMessage: undefined
};
} else {
return {
valid: false,
data: undefined,
errorMessage: this._ajv.errorsText(ajvValidator.errors)
};
}
};
}
}

View file

@ -0,0 +1,78 @@
/**
* Cloudflare Worker-compatible JSON Schema validator provider
*
* This provider uses @cfworker/json-schema for validation without code generation,
* making it compatible with edge runtimes like Cloudflare Workers that restrict
* eval and new Function.
*
*/
import { type Schema, Validator } from '@cfworker/json-schema';
import type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } from './types.js';
/**
* JSON Schema draft version supported by @cfworker/json-schema
*/
export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12';
/**
*
* @example
* ```typescript
* // Use with default configuration (2020-12, shortcircuit)
* const validator = new CfWorkerJsonSchemaValidator();
*
* // Use with custom configuration
* const validator = new CfWorkerJsonSchemaValidator({
* draft: '2020-12',
* shortcircuit: false // Report all errors
* });
* ```
*/
export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator {
private shortcircuit: boolean;
private draft: CfWorkerSchemaDraft;
/**
* Create a validator
*
* @param options - Configuration options
* @param options.shortcircuit - If true, stop validation after first error (default: true)
* @param options.draft - JSON Schema draft version to use (default: '2020-12')
*/
constructor(options?: { shortcircuit?: boolean; draft?: CfWorkerSchemaDraft }) {
this.shortcircuit = options?.shortcircuit ?? true;
this.draft = options?.draft ?? '2020-12';
}
/**
* Create a validator for the given JSON Schema
*
* Unlike AJV, this validator is not cached internally
*
* @param schema - Standard JSON Schema object
* @returns A validator function that validates input data
*/
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
const cfSchema = schema as unknown as Schema;
const validator = new Validator(cfSchema, this.draft, this.shortcircuit);
return (input: unknown): JsonSchemaValidatorResult<T> => {
const result = validator.validate(input);
if (result.valid) {
return {
valid: true,
data: input as T,
errorMessage: undefined
};
} else {
return {
valid: false,
data: undefined,
errorMessage: result.errors.map(err => `${err.instanceLocation}: ${err.error}`).join('; ')
};
}
};
}
}

30
src/validation/index.ts Normal file
View file

@ -0,0 +1,30 @@
/**
* JSON Schema validation
*
* This module provides configurable JSON Schema validation for the MCP SDK.
* Choose a validator based on your runtime environment:
*
* - AjvJsonSchemaValidator: Best for Node.js (default, fastest)
* Import from: @modelcontextprotocol/sdk/validation/ajv
* Requires peer dependencies: ajv, ajv-formats
*
* - CfWorkerJsonSchemaValidator: Best for edge runtimes
* Import from: @modelcontextprotocol/sdk/validation/cfworker
* Requires peer dependency: @cfworker/json-schema
*
* @example
* ```typescript
* // For Node.js with AJV
* import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv';
* const validator = new AjvJsonSchemaValidator();
*
* // For Cloudflare Workers
* import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/cfworker';
* const validator = new CfWorkerJsonSchemaValidator();
* ```
*
* @module validation
*/
// Core types only - implementations are exported via separate entry points
export type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } from './types.js';

52
src/validation/types.ts Normal file
View file

@ -0,0 +1,52 @@
import type { Schema } from '@cfworker/json-schema';
/**
* Result of a JSON Schema validation operation
*/
export type JsonSchemaValidatorResult<T> =
| { valid: true; data: T; errorMessage: undefined }
| { valid: false; data: undefined; errorMessage: string };
/**
* A validator function that validates data against a JSON Schema
*/
export type JsonSchemaValidator<T> = (input: unknown) => JsonSchemaValidatorResult<T>;
/**
* Provider interface for creating validators from JSON Schemas
*
* This is the main extension point for custom validator implementations.
* Implementations should:
* - Support JSON Schema Draft 2020-12 (or be compatible with it)
* - Return validator functions that can be called multiple times
* - Handle schema compilation/caching internally
* - Provide clear error messages on validation failure
*
* @example
* ```typescript
* class MyValidatorProvider implements jsonSchemaValidator {
* getValidator<T>(schema: JsonSchemaType<T>): JsonSchemaValidator<T> {
* // Compile/cache validator from schema
* return (input: unknown) => {
* // Validate input against schema
* if (valid) {
* return { valid: true, data: input as T, errorMessage: undefined };
* } else {
* return { valid: false, data: undefined, errorMessage: 'Error details' };
* }
* };
* }
* }
* ```
*/
export interface jsonSchemaValidator {
/**
* Create a validator for the given JSON Schema
*
* @param schema - Standard JSON Schema object
* @returns A validator function that can be called multiple times
*/
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T>;
}
export type JsonSchemaType = Schema;

View file

@ -0,0 +1,623 @@
/**
* Tests all validator providers with various JSON Schema 2020-12 features
* Based on MCP specification for elicitation schemas:
* https://modelcontextprotocol.io/specification/draft/client/elicitation.md
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { AjvJsonSchemaValidator } from './ajv-provider.js';
import { CfWorkerJsonSchemaValidator } from './cfworker-provider.js';
import type { JsonSchemaType } from './types.js';
// Test with both AJV and CfWorker validators
// AJV validator will use default configuration with format validation enabled
const validators = [
{ name: 'AJV', provider: new AjvJsonSchemaValidator() },
{ name: 'CfWorker', provider: new CfWorkerJsonSchemaValidator() }
];
describe('JSON Schema Validators', () => {
describe.each(validators)('$name Validator', ({ provider }) => {
describe('String schemas', () => {
it('validates basic string', () => {
const schema: JsonSchemaType = {
type: 'string'
};
const validator = provider.getValidator(schema);
const validResult = validator('hello');
expect(validResult.valid).toBe(true);
expect(validResult.data).toBe('hello');
const invalidResult = validator(123);
expect(invalidResult.valid).toBe(false);
expect(invalidResult.errorMessage).toBeDefined();
});
it('validates string with title and description', () => {
const schema: JsonSchemaType = {
type: 'string',
title: 'Name',
description: "User's full name"
};
const validator = provider.getValidator(schema);
const result = validator('John Doe');
expect(result.valid).toBe(true);
expect(result.data).toBe('John Doe');
});
it('validates string with length constraints', () => {
const schema: JsonSchemaType = {
type: 'string',
minLength: 3,
maxLength: 10
};
const validator = provider.getValidator(schema);
expect(validator('abc').valid).toBe(true);
expect(validator('abcdefghij').valid).toBe(true);
expect(validator('ab').valid).toBe(false);
expect(validator('abcdefghijk').valid).toBe(false);
});
it('validates email format', () => {
const schema: JsonSchemaType = {
type: 'string',
format: 'email'
};
const validator = provider.getValidator(schema);
expect(validator('user@example.com').valid).toBe(true);
expect(validator('invalid-email').valid).toBe(false);
});
it('validates URI format', () => {
const schema: JsonSchemaType = {
type: 'string',
format: 'uri'
};
const validator = provider.getValidator(schema);
expect(validator('https://example.com').valid).toBe(true);
expect(validator('not-a-uri').valid).toBe(false);
});
it('validates date-time format', () => {
const schema: JsonSchemaType = {
type: 'string',
format: 'date-time'
};
const validator = provider.getValidator(schema);
expect(validator('2025-10-17T12:00:00Z').valid).toBe(true);
expect(validator('not-a-date').valid).toBe(false);
});
it('validates string pattern', () => {
const schema: JsonSchemaType = {
type: 'string',
pattern: '^[A-Z]{3}$'
};
const validator = provider.getValidator(schema);
expect(validator('ABC').valid).toBe(true);
expect(validator('abc').valid).toBe(false);
expect(validator('ABCD').valid).toBe(false);
});
});
describe('Number schemas', () => {
it('validates number type', () => {
const schema: JsonSchemaType = {
type: 'number'
};
const validator = provider.getValidator(schema);
expect(validator(42).valid).toBe(true);
expect(validator(3.14).valid).toBe(true);
expect(validator('42').valid).toBe(false);
});
it('validates integer type', () => {
const schema: JsonSchemaType = {
type: 'integer'
};
const validator = provider.getValidator(schema);
expect(validator(42).valid).toBe(true);
expect(validator(3.14).valid).toBe(false);
});
it('validates number range', () => {
const schema: JsonSchemaType = {
type: 'number',
minimum: 0,
maximum: 100
};
const validator = provider.getValidator(schema);
expect(validator(0).valid).toBe(true);
expect(validator(50).valid).toBe(true);
expect(validator(100).valid).toBe(true);
expect(validator(-1).valid).toBe(false);
expect(validator(101).valid).toBe(false);
});
});
describe('Boolean schemas', () => {
it('validates boolean type', () => {
const schema: JsonSchemaType = {
type: 'boolean'
};
const validator = provider.getValidator(schema);
expect(validator(true).valid).toBe(true);
expect(validator(false).valid).toBe(true);
expect(validator('true').valid).toBe(false);
expect(validator(1).valid).toBe(false);
});
it('validates boolean with default', () => {
const schema: JsonSchemaType = {
type: 'boolean',
default: false
};
const validator = provider.getValidator(schema);
expect(validator(true).valid).toBe(true);
expect(validator(false).valid).toBe(true);
});
});
describe('Enum schemas', () => {
it('validates enum values', () => {
const schema: JsonSchemaType = {
enum: ['red', 'green', 'blue']
};
const validator = provider.getValidator(schema);
expect(validator('red').valid).toBe(true);
expect(validator('green').valid).toBe(true);
expect(validator('blue').valid).toBe(true);
expect(validator('yellow').valid).toBe(false);
});
it('validates enum with mixed types', () => {
const schema: JsonSchemaType = {
enum: ['option1', 42, true, null]
};
const validator = provider.getValidator(schema);
expect(validator('option1').valid).toBe(true);
expect(validator(42).valid).toBe(true);
expect(validator(true).valid).toBe(true);
expect(validator(null).valid).toBe(true);
expect(validator('other').valid).toBe(false);
});
});
describe('Object schemas', () => {
it('validates simple object', () => {
const schema: JsonSchemaType = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' }
},
required: ['name']
};
const validator = provider.getValidator(schema);
expect(validator({ name: 'John', age: 30 }).valid).toBe(true);
expect(validator({ name: 'John' }).valid).toBe(true);
expect(validator({ age: 30 }).valid).toBe(false);
expect(validator({}).valid).toBe(false);
});
it('validates nested objects', () => {
const schema: JsonSchemaType = {
type: 'object',
properties: {
user: {
type: 'object',
properties: {
name: { type: 'string' },
email: { type: 'string', format: 'email' }
},
required: ['name']
}
},
required: ['user']
};
const validator = provider.getValidator(schema);
expect(
validator({
user: { name: 'John', email: 'john@example.com' }
}).valid
).toBe(true);
expect(
validator({
user: { name: 'John' }
}).valid
).toBe(true);
expect(
validator({
user: { email: 'john@example.com' }
}).valid
).toBe(false);
});
it('validates object with additionalProperties: false', () => {
const schema: JsonSchemaType = {
type: 'object',
properties: {
name: { type: 'string' }
},
additionalProperties: false
};
const validator = provider.getValidator(schema);
expect(validator({ name: 'John' }).valid).toBe(true);
expect(validator({ name: 'John', extra: 'field' }).valid).toBe(false);
});
});
describe('Array schemas', () => {
it('validates array of strings', () => {
const schema: JsonSchemaType = {
type: 'array',
items: { type: 'string' }
};
const validator = provider.getValidator(schema);
expect(validator(['a', 'b', 'c']).valid).toBe(true);
expect(validator([]).valid).toBe(true);
expect(validator(['a', 1, 'c']).valid).toBe(false);
});
it('validates array length constraints', () => {
const schema: JsonSchemaType = {
type: 'array',
items: { type: 'number' },
minItems: 1,
maxItems: 3
};
const validator = provider.getValidator(schema);
expect(validator([1]).valid).toBe(true);
expect(validator([1, 2, 3]).valid).toBe(true);
expect(validator([]).valid).toBe(false);
expect(validator([1, 2, 3, 4]).valid).toBe(false);
});
it('validates array with unique items', () => {
const schema: JsonSchemaType = {
type: 'array',
items: { type: 'number' },
uniqueItems: true
};
const validator = provider.getValidator(schema);
expect(validator([1, 2, 3]).valid).toBe(true);
expect(validator([1, 2, 2, 3]).valid).toBe(false);
});
});
describe('JSON Schema 2020-12 features', () => {
it('validates schema with $schema field', () => {
const schema: JsonSchemaType = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'string'
};
const validator = provider.getValidator(schema);
expect(validator('test').valid).toBe(true);
});
it('validates schema with $id field', () => {
const schema: JsonSchemaType = {
$id: 'https://example.com/schemas/test',
type: 'number'
};
const validator = provider.getValidator(schema);
expect(validator(42).valid).toBe(true);
});
it('validates with allOf', () => {
const schema: JsonSchemaType = {
allOf: [
{ type: 'object', properties: { name: { type: 'string' } } },
{ type: 'object', properties: { age: { type: 'number' } } }
]
};
const validator = provider.getValidator(schema);
expect(validator({ name: 'John', age: 30 }).valid).toBe(true);
expect(validator({ name: 'John' }).valid).toBe(true);
expect(validator({ name: 123 }).valid).toBe(false);
});
it('validates with anyOf', () => {
const schema: JsonSchemaType = {
anyOf: [{ type: 'string' }, { type: 'number' }]
};
const validator = provider.getValidator(schema);
expect(validator('test').valid).toBe(true);
expect(validator(42).valid).toBe(true);
expect(validator(true).valid).toBe(false);
});
it('validates with oneOf', () => {
const schema: JsonSchemaType = {
oneOf: [
{ type: 'string', minLength: 5 },
{ type: 'string', maxLength: 3 }
]
};
const validator = provider.getValidator(schema);
expect(validator('ab').valid).toBe(true); // Matches second only
expect(validator('hello').valid).toBe(true); // Matches first only
expect(validator('abcd').valid).toBe(false); // Matches neither
});
it('validates with not', () => {
const schema: JsonSchemaType = {
not: { type: 'null' }
};
const validator = provider.getValidator(schema);
expect(validator('test').valid).toBe(true);
expect(validator(42).valid).toBe(true);
expect(validator(null).valid).toBe(false);
});
it('validates with const', () => {
const schema: JsonSchemaType = {
const: 'specific-value'
};
const validator = provider.getValidator(schema);
expect(validator('specific-value').valid).toBe(true);
expect(validator('other-value').valid).toBe(false);
});
});
describe('Complex real-world schemas', () => {
it('validates user registration form', () => {
const schema: JsonSchemaType = {
type: 'object',
properties: {
username: {
type: 'string',
minLength: 3,
maxLength: 20,
pattern: '^[a-zA-Z0-9_]+$'
},
email: {
type: 'string',
format: 'email'
},
age: {
type: 'integer',
minimum: 18,
maximum: 120
},
newsletter: {
type: 'boolean',
default: false
}
},
required: ['username', 'email']
};
const validator = provider.getValidator(schema);
expect(
validator({
username: 'john_doe',
email: 'john@example.com',
age: 25,
newsletter: true
}).valid
).toBe(true);
expect(
validator({
username: 'john_doe',
email: 'john@example.com'
}).valid
).toBe(true);
expect(
validator({
username: 'ab', // Too short
email: 'john@example.com'
}).valid
).toBe(false);
expect(
validator({
username: 'john_doe',
email: 'invalid-email'
}).valid
).toBe(false);
});
it('validates API response with nested structure', () => {
const schema: JsonSchemaType = {
type: 'object',
properties: {
status: {
type: 'string',
enum: ['success', 'error', 'pending']
},
data: {
type: 'object',
properties: {
id: { type: 'string' },
items: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
quantity: { type: 'integer', minimum: 1 }
},
required: ['name', 'quantity']
}
}
},
required: ['id', 'items']
},
timestamp: {
type: 'string',
format: 'date-time'
}
},
required: ['status', 'data']
};
const validator = provider.getValidator(schema);
expect(
validator({
status: 'success',
data: {
id: '123',
items: [
{ name: 'Item 1', quantity: 5 },
{ name: 'Item 2', quantity: 3 }
]
},
timestamp: '2025-10-17T12:00:00Z'
}).valid
).toBe(true);
expect(
validator({
status: 'invalid-status',
data: { id: '123', items: [] }
}).valid
).toBe(false);
});
});
describe('Error messages', () => {
it('provides helpful error message on validation failure', () => {
const schema: JsonSchemaType = {
type: 'object',
properties: {
name: { type: 'string' }
},
required: ['name']
};
const validator = provider.getValidator(schema);
const result = validator({});
expect(result.valid).toBe(false);
expect(result.errorMessage).toBeDefined();
expect(result.errorMessage).toBeTruthy();
expect(typeof result.errorMessage).toBe('string');
});
});
});
});
describe('Missing dependencies', () => {
describe('AJV not installed but CfWorker is', () => {
beforeEach(() => {
jest.resetModules();
});
afterEach(() => {
jest.unmock('ajv');
jest.unmock('ajv-formats');
});
it('should throw error when trying to import ajv-provider without ajv', async () => {
// Mock ajv as not installed
jest.doMock('ajv', () => {
throw new Error("Cannot find module 'ajv'");
});
jest.doMock('ajv-formats', () => {
throw new Error("Cannot find module 'ajv-formats'");
});
// Attempting to import ajv-provider should fail
await expect(import('./ajv-provider.js')).rejects.toThrow();
});
it('should be able to import cfworker-provider when ajv is missing', async () => {
// Mock ajv as not installed
jest.doMock('ajv', () => {
throw new Error("Cannot find module 'ajv'");
});
jest.doMock('ajv-formats', () => {
throw new Error("Cannot find module 'ajv-formats'");
});
// But cfworker-provider should import successfully
const cfworkerModule = await import('./cfworker-provider.js');
expect(cfworkerModule.CfWorkerJsonSchemaValidator).toBeDefined();
// And should work correctly
const validator = new cfworkerModule.CfWorkerJsonSchemaValidator();
const schema: JsonSchemaType = { type: 'string' };
const validatorFn = validator.getValidator(schema);
expect(validatorFn('test').valid).toBe(true);
});
});
describe('CfWorker not installed but AJV is', () => {
beforeEach(() => {
jest.resetModules();
});
afterEach(() => {
jest.unmock('@cfworker/json-schema');
});
it('should throw error when trying to import cfworker-provider without @cfworker/json-schema', async () => {
// Mock @cfworker/json-schema as not installed
jest.doMock('@cfworker/json-schema', () => {
throw new Error("Cannot find module '@cfworker/json-schema'");
});
// Attempting to import cfworker-provider should fail
await expect(import('./cfworker-provider.js')).rejects.toThrow();
});
it('should be able to import ajv-provider when @cfworker/json-schema is missing', async () => {
// Mock @cfworker/json-schema as not installed
jest.doMock('@cfworker/json-schema', () => {
throw new Error("Cannot find module '@cfworker/json-schema'");
});
// But ajv-provider should import successfully
const ajvModule = await import('./ajv-provider.js');
expect(ajvModule.AjvJsonSchemaValidator).toBeDefined();
// And should work correctly
const validator = new ajvModule.AjvJsonSchemaValidator();
const schema: JsonSchemaType = { type: 'string' };
const validatorFn = validator.getValidator(schema);
expect(validatorFn('test').valid).toBe(true);
});
it('should document that @cfworker/json-schema is required', () => {
const cfworkerProviderPath = join(__dirname, 'cfworker-provider.ts');
const content = readFileSync(cfworkerProviderPath, 'utf-8');
expect(content).toContain('@cfworker/json-schema');
});
});
});