diff --git a/package-lock.json b/package-lock.json index f400735f5..d5a59ce5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.19.1", "license": "MIT", "dependencies": { - "ajv": "^6.12.6", + "@cfworker/json-schema": "^4.1.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -529,6 +529,12 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", @@ -2228,6 +2234,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -3493,7 +3500,8 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true }, "node_modules/fast-glob": { "version": "3.3.2", @@ -3526,7 +3534,8 @@ "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", @@ -4918,6 +4927,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -5608,6 +5618,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6495,6 +6506,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" diff --git a/package.json b/package.json index cda66abe3..870bf5955 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "client": "tsx src/cli.ts client" }, "dependencies": { - "ajv": "^6.12.6", + "@cfworker/json-schema": "^4.1.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -91,8 +91,8 @@ "@types/ws": "^8.5.12", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", - "prettier": "3.6.2", "jest": "^29.7.0", + "prettier": "3.6.2", "supertest": "^7.0.0", "ts-jest": "^29.2.4", "tsx": "^4.16.5", diff --git a/src/client/index.ts b/src/client/index.ts index 856eb18e5..b8ef804b4 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -38,8 +38,7 @@ import { ErrorCode, McpError } from '../types.js'; -import Ajv from 'ajv'; -import type { ValidateFunction } from 'ajv'; +import { Validator, Schema } from '@cfworker/json-schema'; export type ClientOptions = ProtocolOptions & { /** @@ -82,8 +81,7 @@ export class Client< private _serverVersion?: Implementation; private _capabilities: ClientCapabilities; private _instructions?: string; - private _cachedToolOutputValidators: Map = new Map(); - private _ajv: InstanceType; + private _cachedToolOutputValidators: Map = new Map(); /** * Initializes this client with the given name and version information. @@ -94,7 +92,6 @@ export class Client< ) { super(options); this._capabilities = options?.capabilities ?? {}; - this._ajv = new Ajv(); } /** @@ -348,12 +345,14 @@ export class Client< if (result.structuredContent) { try { // Validate the structured content (which is already an object) against the schema - const isValid = validator(result.structuredContent); + const validationResult = validator.validate(result.structuredContent); + + if (!validationResult.valid) { + const errorMessages = validationResult.errors.map(error => `${error.instanceLocation}: ${error.error}`).join('; '); - if (!isValid) { 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: ${errorMessages}` ); } } catch (error) { @@ -375,10 +374,10 @@ export class Client< 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); + const validator = new Validator(tool.outputSchema as Schema, '2020-12'); this._cachedToolOutputValidators.set(tool.name, validator); } catch { // Ignore schema compilation errors @@ -387,7 +386,7 @@ export class Client< } } - private getToolOutputValidator(toolName: string): ValidateFunction | undefined { + private getToolOutputValidator(toolName: string): Validator | undefined { return this._cachedToolOutputValidators.get(toolName); } diff --git a/src/server/elicitation.test.ts b/src/server/elicitation.test.ts new file mode 100644 index 000000000..37ef3be8a --- /dev/null +++ b/src/server/elicitation.test.ts @@ -0,0 +1,958 @@ +import { InMemoryTransport } from '../inMemory.js'; +import { Client } from '../client/index.js'; +import { Server } from './index.js'; +import { ElicitRequestSchema } from '../types.js'; + +let client: Client; +let server: Server; +let clientTransport: InMemoryTransport; +let serverTransport: InMemoryTransport; + +beforeEach(async () => { + [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { elicitation: {} } }); + client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); +}); + +afterEach(async () => { + await client.close(); + await server.close(); +}); + +describe('Validation Rules', () => { + test('should validate content when action is "accept" and content is provided', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { + name: 'Jane Smith', + email: 'jane@example.com' + } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Enter basic info', + requestedSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + email: { type: 'string', format: 'email' }, + phone: { type: 'string' }, // Optional + website: { type: 'string', format: 'uri' } // Optional + }, + required: ['name', 'email'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ + name: 'Jane Smith', + email: 'jane@example.com' + }); + }); + + test('should NOT validate when action is "decline"', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'decline' + // No content provided, and validation should be skipped + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Enter your details', + requestedSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + }, + required: ['name', 'age'] + } + }); + + expect(result.action).toBe('decline'); + expect(result.content).toBeUndefined(); + }); + + test('should NOT validate when action is "cancel"', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'cancel' + // No content provided, and validation should be skipped + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Enter your details', + requestedSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + }, + required: ['name', 'age'] + } + }); + + expect(result.action).toBe('cancel'); + expect(result.content).toBeUndefined(); + }); + + test('should NOT validate when action is "accept" but content is null or undefined', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: undefined + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Enter your details', + requestedSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + }, + required: ['name', 'age'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toBeUndefined(); + }); + + test('should provide detailed error messages for validation failures', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { + name: '', // Too short + email: 'invalid', // Wrong format + age: 'thirty', // Wrong type + score: 150 // Above maximum + } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + try { + await server.elicitInput({ + message: 'Enter valid data', + requestedSchema: { + type: 'object', + properties: { + name: { type: 'string', minLength: 1 }, + email: { type: 'string', format: 'email' }, + age: { type: 'number', minimum: 0 }, + score: { type: 'number', maximum: 100 } + }, + required: ['name', 'email', 'age', 'score'] + } + }); + fail('Should have thrown validation error'); + } catch (error: unknown) { + expect(error).toBeInstanceOf(Error); + const errorMessage = (error as Error).message; + expect(errorMessage).toContain('does not match requested schema'); + // Should contain multiple validation errors + expect(errorMessage.length).toBeGreaterThan(100); + } + }); +}); + +describe('JSON Schema Validation (@cfworker/json-schema)', () => { + describe('String Schema (MCP Spec)', () => { + test('should validate basic string type', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { name: 'John Doe' } + })); + + 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' } + }, + required: ['name'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ name: 'John Doe' }); + }); + + test('should validate string with title and description', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { displayName: 'Administrator' } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Enter display name', + requestedSchema: { + type: 'object', + properties: { + displayName: { + type: 'string', + title: 'Display Name', + description: 'The name to show in the UI' + } + }, + required: ['displayName'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ displayName: 'Administrator' }); + }); + + test('should validate string length constraints', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { username: 'user123' } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Enter username', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string', + minLength: 3, + maxLength: 20 + } + }, + required: ['username'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ username: 'user123' }); + }); + + test('should reject string that is too short', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { username: 'ab' } // Too short + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + message: 'Enter username', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string', + minLength: 3, + maxLength: 20 + } + }, + required: ['username'] + } + }) + ).rejects.toThrow(/does not match requested schema/); + }); + + test('should reject string that is too long', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { username: 'a'.repeat(25) } // Too long + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + message: 'Enter username', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string', + minLength: 3, + maxLength: 20 + } + }, + required: ['username'] + } + }) + ).rejects.toThrow(/does not match requested schema/); + }); + }); + + describe('String Format Validation (MCP Spec)', () => { + test('should validate email format', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { email: 'test@example.com' } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Enter email', + requestedSchema: { + type: 'object', + properties: { + email: { + type: 'string', + format: 'email' + } + }, + required: ['email'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ email: 'test@example.com' }); + }); + + test('should reject invalid email format', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { email: 'invalid-email' } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + message: 'Enter email', + requestedSchema: { + type: 'object', + properties: { + email: { + type: 'string', + format: 'email' + } + }, + required: ['email'] + } + }) + ).rejects.toThrow(/does not match requested schema/); + }); + + test('should validate URI format', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { website: 'https://example.com' } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Enter website', + requestedSchema: { + type: 'object', + properties: { + website: { + type: 'string', + format: 'uri' + } + }, + required: ['website'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ website: 'https://example.com' }); + }); + + test('should validate date format', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { birthDate: '1990-01-01' } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Enter birth date', + requestedSchema: { + type: 'object', + properties: { + birthDate: { + type: 'string', + format: 'date' + } + }, + required: ['birthDate'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ birthDate: '1990-01-01' }); + }); + + test('should validate date-time format', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { timestamp: '2023-12-01T10:30:00Z' } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Enter timestamp', + requestedSchema: { + type: 'object', + properties: { + timestamp: { + type: 'string', + format: 'date-time' + } + }, + required: ['timestamp'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ timestamp: '2023-12-01T10:30:00Z' }); + }); + }); + + describe('Number Schema (MCP Spec)', () => { + test('should validate number type', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { price: 19.99 } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Enter price', + requestedSchema: { + type: 'object', + properties: { + price: { type: 'number' } + }, + required: ['price'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ price: 19.99 }); + }); + + test('should validate integer type', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { count: 42 } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Enter count', + requestedSchema: { + type: 'object', + properties: { + count: { type: 'integer' } + }, + required: ['count'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ count: 42 }); + }); + + test('should validate number with minimum/maximum constraints', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { age: 25 } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Enter age', + requestedSchema: { + type: 'object', + properties: { + age: { + type: 'number', + minimum: 0, + maximum: 120 + } + }, + required: ['age'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ age: 25 }); + }); + + test('should reject number below minimum', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { age: -5 } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + message: 'Enter age', + requestedSchema: { + type: 'object', + properties: { + age: { + type: 'number', + minimum: 0, + maximum: 120 + } + }, + required: ['age'] + } + }) + ).rejects.toThrow(/does not match requested schema/); + }); + + test('should reject number above maximum', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { age: 150 } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + message: 'Enter age', + requestedSchema: { + type: 'object', + properties: { + age: { + type: 'number', + minimum: 0, + maximum: 120 + } + }, + required: ['age'] + } + }) + ).rejects.toThrow(/does not match requested schema/); + }); + }); + + describe('Boolean Schema (MCP Spec)', () => { + test('should validate boolean type', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { isActive: true } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Set active status', + requestedSchema: { + type: 'object', + properties: { + isActive: { type: 'boolean' } + }, + required: ['isActive'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ isActive: true }); + }); + + test('should validate boolean with default value', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { enableNotifications: false } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Enable notifications', + requestedSchema: { + type: 'object', + properties: { + enableNotifications: { + type: 'boolean', + title: 'Enable Notifications', + description: 'Whether to enable push notifications', + default: false + } + }, + required: ['enableNotifications'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ enableNotifications: false }); + }); + + test('should reject non-boolean value', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { isActive: 'yes' } // Should be boolean + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + message: 'Set active status', + requestedSchema: { + type: 'object', + properties: { + isActive: { type: 'boolean' } + }, + required: ['isActive'] + } + }) + ).rejects.toThrow(/does not match requested schema/); + }); + }); + + describe('Enum Schema (MCP Spec)', () => { + test('should validate standard enum', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { priority: 'high' } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Set priority', + requestedSchema: { + type: 'object', + properties: { + priority: { + type: 'string', + enum: ['low', 'medium', 'high'] + } + }, + required: ['priority'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ priority: 'high' }); + }); + + test('should validate enum with enumNames (deprecated but supported)', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { level: 'debug' } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Set log level', + requestedSchema: { + type: 'object', + properties: { + level: { + type: 'string', + title: 'Log Level', + description: 'Choose logging level', + enum: ['debug', 'info', 'warn', 'error'], + enumNames: ['Debug', 'Information', 'Warning', 'Error'] + } + }, + required: ['level'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ level: 'debug' }); + }); + + test('should reject invalid enum value', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { priority: 'urgent' } // Not in enum + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + message: 'Set priority', + requestedSchema: { + type: 'object', + properties: { + priority: { + type: 'string', + enum: ['low', 'medium', 'high'] + } + }, + required: ['priority'] + } + }) + ).rejects.toThrow(/does not match requested schema/); + }); + }); + + describe('Complex Object Validation', () => { + test('should validate complex object with multiple property types', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { + name: 'John Doe', + email: 'john@example.com', + age: 30, + isActive: true, + role: 'admin', + website: 'https://johndoe.com', + joinDate: '2023-01-15', + lastLogin: '2023-12-01T10:30:00Z', + score: 95.5 + } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Enter user profile', + requestedSchema: { + type: 'object', + properties: { + name: { + type: 'string', + title: 'Full Name', + minLength: 1, + maxLength: 100 + }, + email: { + type: 'string', + format: 'email', + title: 'Email Address' + }, + age: { + type: 'integer', + minimum: 18, + maximum: 65, + title: 'Age' + }, + isActive: { + type: 'boolean', + title: 'Active Status', + default: true + }, + role: { + type: 'string', + enum: ['user', 'admin', 'moderator'], + title: 'Role' + }, + website: { + type: 'string', + format: 'uri', + title: 'Website URL' + }, + joinDate: { + type: 'string', + format: 'date', + title: 'Join Date' + }, + lastLogin: { + type: 'string', + format: 'date-time', + title: 'Last Login' + }, + score: { + type: 'number', + minimum: 0, + maximum: 100, + title: 'Performance Score' + } + }, + required: ['name', 'email', 'age', 'isActive', 'role'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ + name: 'John Doe', + email: 'john@example.com', + age: 30, + isActive: true, + role: 'admin', + website: 'https://johndoe.com', + joinDate: '2023-01-15', + lastLogin: '2023-12-01T10:30:00Z', + score: 95.5 + }); + }); + + test('should handle optional properties correctly', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { + name: 'Jane Smith', + email: 'jane@example.com' + // Optional properties not provided + } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Enter basic info', + requestedSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + email: { type: 'string', format: 'email' }, + phone: { type: 'string' }, // Optional + website: { type: 'string', format: 'uri' } // Optional + }, + required: ['name', 'email'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ + name: 'Jane Smith', + email: 'jane@example.com' + }); + }); + }); + + describe('JSON Schema 2020-12 Specific Features', () => { + test('should validate with $schema reference', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { value: 'test' } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Enter value', + requestedSchema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + value: { type: 'string' } + }, + required: ['value'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ value: 'test' }); + }); + + test('should validate with $id reference', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { data: 'valid' } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Enter data', + requestedSchema: { + $id: 'https://example.com/schemas/test-schema', + type: 'object', + properties: { + data: { type: 'string' } + }, + required: ['data'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ data: 'valid' }); + }); + + test('should validate with additionalProperties: false', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { allowedProp: 'value' } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Enter allowed property', + requestedSchema: { + type: 'object', + properties: { + allowedProp: { type: 'string' } + }, + additionalProperties: false, + required: ['allowedProp'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ allowedProp: 'value' }); + }); + + test('should reject additional properties when additionalProperties: false', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { + allowedProp: 'value', + extraProp: 'not allowed' // Should be rejected + } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + message: 'Enter allowed property only', + requestedSchema: { + type: 'object', + properties: { + allowedProp: { type: 'string' } + }, + additionalProperties: false, + required: ['allowedProp'] + } + }) + ).rejects.toThrow(/does not match requested schema/); + }); + }); + + describe('Edge Cases', () => { + test('should handle empty object schema', async () => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: {} + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + message: 'Submit empty form', + requestedSchema: { + type: 'object', + properties: {} + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({}); + }); + }); +}); diff --git a/src/server/index.ts b/src/server/index.ts index 3eb0ba0d4..b7ca2ae70 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -31,7 +31,7 @@ import { SetLevelRequestSchema, LoggingLevelSchema } from '../types.js'; -import Ajv from 'ajv'; +import { Validator } from '@cfworker/json-schema'; export type ServerOptions = ProtocolOptions & { /** @@ -291,15 +291,15 @@ export class Server< // Validate the response content against the requested schema if action is "accept" if (result.action === 'accept' && result.content) { try { - const ajv = new Ajv(); + const validator = new Validator(params.requestedSchema, '2020-12'); + const validationResult = validator.validate(result.content); - const validate = ajv.compile(params.requestedSchema); - const isValid = validate(result.content); + if (!validationResult.valid) { + const errorMessages = validationResult.errors.map(error => `${error.instanceLocation}: ${error.error}`).join('; '); - if (!isValid) { 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: ${errorMessages}` ); } } catch (error) {