diff --git a/package-lock.json b/package-lock.json index dc3752f98..0f614d70e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.20.2", "license": "MIT", "dependencies": { - "ajv": "^6.12.6", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -23,6 +24,7 @@ "zod-to-json-schema": "^3.24.1" }, "devDependencies": { + "@cfworker/json-schema": "^4.1.1", "@eslint/js": "^9.8.0", "@jest-mock/express": "^3.0.0", "@types/content-type": "^1.1.8", @@ -48,6 +50,22 @@ }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "ajv": { + "optional": true + }, + "ajv-formats": { + "optional": true + } } }, "node_modules/@ampproject/remapping": { @@ -529,6 +547,13 @@ "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==", + "dev": true, + "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", @@ -1039,6 +1064,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "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", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "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/@eslint/js": { "version": "9.13.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz", @@ -2225,21 +2274,38 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -3259,6 +3325,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "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", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "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/espree": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", @@ -3526,7 +3616,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", @@ -3541,6 +3632,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -4915,9 +5022,9 @@ "dev": true }, "node_modules/json-schema-traverse": { - "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==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -5608,6 +5715,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" @@ -5713,6 +5821,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -6495,6 +6612,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 418209efc..22a5b41cc 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,18 @@ "import": "./dist/esm/server/index.js", "require": "./dist/cjs/server/index.js" }, + "./validation": { + "import": "./dist/esm/validation/index.js", + "require": "./dist/cjs/validation/index.js" + }, + "./validation/ajv": { + "import": "./dist/esm/validation/ajv-provider.js", + "require": "./dist/cjs/validation/ajv-provider.js" + }, + "./validation/cfworker": { + "import": "./dist/esm/validation/cfworker-provider.js", + "require": "./dist/cjs/validation/cfworker-provider.js" + }, "./*": { "import": "./dist/esm/*", "require": "./dist/cjs/*" @@ -63,7 +75,8 @@ "client": "tsx src/cli.ts client" }, "dependencies": { - "ajv": "^6.12.6", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -76,7 +89,16 @@ "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + } + }, "devDependencies": { + "@cfworker/json-schema": "^4.1.1", "@eslint/js": "^9.8.0", "@jest-mock/express": "^3.0.0", "@types/content-type": "^1.1.8", @@ -91,8 +113,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..cc6819815 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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 = new Map(); - private _ajv: InstanceType; + private _jsonSchemaValidator: jsonSchemaValidator; + private _cachedToolOutputValidators: Map> = 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 | undefined { return this._cachedToolOutputValidators.get(toolName); } diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 10f6afcbe..bf58a7a79 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -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 { 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) { diff --git a/src/examples/server/elicitationExample.ts b/src/examples/server/elicitationExample.ts new file mode 100644 index 000000000..8b1f81d12 --- /dev/null +++ b/src/examples/server/elicitationExample.ts @@ -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); +}); diff --git a/src/server/elicitation.test.ts b/src/server/elicitation.test.ts new file mode 100644 index 000000000..845a08cb2 --- /dev/null +++ b/src/server/elicitation.test.ts @@ -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/); + }); +} diff --git a/src/server/index.test.ts b/src/server/index.test.ts index d056707fe..bea9c31d6 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -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', diff --git a/src/server/index.ts b/src/server/index.ts index 3eb0ba0d4..c7f571f62 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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, + ErrorCode, + type Implementation, InitializedNotificationSchema, - InitializeRequest, + type InitializeRequest, InitializeRequestSchema, - InitializeResult, + type InitializeResult, LATEST_PROTOCOL_VERSION, - ListRootsRequest, + type ListRootsRequest, ListRootsResultSchema, - LoggingMessageNotification, + type LoggingLevel, + LoggingLevelSchema, + type LoggingMessageNotification, McpError, - ErrorCode, - Notification, - Request, - ResourceUpdatedNotification, - Result, - ServerCapabilities, - ServerNotification, - ServerRequest, - ServerResult, - SUPPORTED_PROTOCOL_VERSIONS, - LoggingLevel, + 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 validate = ajv.compile(params.requestedSchema); - const isValid = validate(result.content); + const validator = this._jsonSchemaValidator.getValidator(params.requestedSchema as JsonSchemaType); + const validationResult = validator(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)}` + ); } } diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 4bb42d7fc..537bdc3ae 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -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: [ { diff --git a/src/validation/ajv-provider.ts b/src/validation/ajv-provider.ts new file mode 100644 index 000000000..115a98521 --- /dev/null +++ b/src/validation/ajv-provider.ts @@ -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(schema: JsonSchemaType): JsonSchemaValidator { + // 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 => { + 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) + }; + } + }; + } +} diff --git a/src/validation/cfworker-provider.ts b/src/validation/cfworker-provider.ts new file mode 100644 index 000000000..60ec3f06e --- /dev/null +++ b/src/validation/cfworker-provider.ts @@ -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(schema: JsonSchemaType): JsonSchemaValidator { + const cfSchema = schema as unknown as Schema; + const validator = new Validator(cfSchema, this.draft, this.shortcircuit); + + return (input: unknown): JsonSchemaValidatorResult => { + 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('; ') + }; + } + }; + } +} diff --git a/src/validation/index.ts b/src/validation/index.ts new file mode 100644 index 000000000..a6df86d6a --- /dev/null +++ b/src/validation/index.ts @@ -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'; diff --git a/src/validation/types.ts b/src/validation/types.ts new file mode 100644 index 000000000..c540b59ff --- /dev/null +++ b/src/validation/types.ts @@ -0,0 +1,52 @@ +import type { Schema } from '@cfworker/json-schema'; + +/** + * Result of a JSON Schema validation operation + */ +export type JsonSchemaValidatorResult = + | { 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 = (input: unknown) => JsonSchemaValidatorResult; + +/** + * 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(schema: JsonSchemaType): JsonSchemaValidator { + * // 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(schema: JsonSchemaType): JsonSchemaValidator; +} + +export type JsonSchemaType = Schema; diff --git a/src/validation/validation.test.ts b/src/validation/validation.test.ts new file mode 100644 index 000000000..ef2e77090 --- /dev/null +++ b/src/validation/validation.test.ts @@ -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'); + }); + }); +});