feat: pluggable JSON schema validator providers (#1012)
This commit is contained in:
parent
7098bff073
commit
5a8fb39430
14 changed files with 2373 additions and 134 deletions
142
package-lock.json
generated
142
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
26
package.json
26
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",
|
||||
|
|
|
|||
|
|
@ -1,51 +1,82 @@
|
|||
import { mergeCapabilities, Protocol, ProtocolOptions, RequestOptions } from '../shared/protocol.js';
|
||||
import { Transport } from '../shared/transport.js';
|
||||
import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js';
|
||||
import type { Transport } from '../shared/transport.js';
|
||||
import {
|
||||
CallToolRequest,
|
||||
type CallToolRequest,
|
||||
CallToolResultSchema,
|
||||
ClientCapabilities,
|
||||
ClientNotification,
|
||||
ClientRequest,
|
||||
ClientResult,
|
||||
CompatibilityCallToolResultSchema,
|
||||
CompleteRequest,
|
||||
type ClientCapabilities,
|
||||
type ClientNotification,
|
||||
type ClientRequest,
|
||||
type ClientResult,
|
||||
type CompatibilityCallToolResultSchema,
|
||||
type CompleteRequest,
|
||||
CompleteResultSchema,
|
||||
EmptyResultSchema,
|
||||
GetPromptRequest,
|
||||
ErrorCode,
|
||||
type GetPromptRequest,
|
||||
GetPromptResultSchema,
|
||||
Implementation,
|
||||
type Implementation,
|
||||
InitializeResultSchema,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
ListPromptsRequest,
|
||||
type ListPromptsRequest,
|
||||
ListPromptsResultSchema,
|
||||
ListResourcesRequest,
|
||||
type ListResourcesRequest,
|
||||
ListResourcesResultSchema,
|
||||
ListResourceTemplatesRequest,
|
||||
type ListResourceTemplatesRequest,
|
||||
ListResourceTemplatesResultSchema,
|
||||
ListToolsRequest,
|
||||
type ListToolsRequest,
|
||||
ListToolsResultSchema,
|
||||
LoggingLevel,
|
||||
Notification,
|
||||
ReadResourceRequest,
|
||||
type LoggingLevel,
|
||||
McpError,
|
||||
type Notification,
|
||||
type ReadResourceRequest,
|
||||
ReadResourceResultSchema,
|
||||
Request,
|
||||
Result,
|
||||
ServerCapabilities,
|
||||
SubscribeRequest,
|
||||
type Request,
|
||||
type Result,
|
||||
type ServerCapabilities,
|
||||
SUPPORTED_PROTOCOL_VERSIONS,
|
||||
UnsubscribeRequest,
|
||||
Tool,
|
||||
ErrorCode,
|
||||
McpError
|
||||
type SubscribeRequest,
|
||||
type Tool,
|
||||
type UnsubscribeRequest
|
||||
} from '../types.js';
|
||||
import Ajv from 'ajv';
|
||||
import type { ValidateFunction } from 'ajv';
|
||||
import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js';
|
||||
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js';
|
||||
|
||||
export type ClientOptions = ProtocolOptions & {
|
||||
/**
|
||||
* Capabilities to advertise as being supported by this client.
|
||||
*/
|
||||
capabilities?: ClientCapabilities;
|
||||
|
||||
/**
|
||||
* JSON Schema validator for tool output validation.
|
||||
*
|
||||
* The validator is used to validate structured content returned by tools
|
||||
* against their declared output schemas.
|
||||
*
|
||||
* @default AjvJsonSchemaValidator
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // ajv
|
||||
* const client = new Client(
|
||||
* { name: 'my-client', version: '1.0.0' },
|
||||
* {
|
||||
* capabilities: {},
|
||||
* jsonSchemaValidator: new AjvJsonSchemaValidator()
|
||||
* }
|
||||
* );
|
||||
*
|
||||
* // @cfworker/json-schema
|
||||
* const client = new Client(
|
||||
* { name: 'my-client', version: '1.0.0' },
|
||||
* {
|
||||
* capabilities: {},
|
||||
* jsonSchemaValidator: new CfWorkerJsonSchemaValidator()
|
||||
* }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
jsonSchemaValidator?: jsonSchemaValidator;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -82,8 +113,8 @@ export class Client<
|
|||
private _serverVersion?: Implementation;
|
||||
private _capabilities: ClientCapabilities;
|
||||
private _instructions?: string;
|
||||
private _cachedToolOutputValidators: Map<string, ValidateFunction> = new Map();
|
||||
private _ajv: InstanceType<typeof Ajv>;
|
||||
private _jsonSchemaValidator: jsonSchemaValidator;
|
||||
private _cachedToolOutputValidators: Map<string, JsonSchemaValidator<unknown>> = new Map();
|
||||
|
||||
/**
|
||||
* Initializes this client with the given name and version information.
|
||||
|
|
@ -94,7 +125,7 @@ export class Client<
|
|||
) {
|
||||
super(options);
|
||||
this._capabilities = options?.capabilities ?? {};
|
||||
this._ajv = new Ajv();
|
||||
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -347,13 +378,13 @@ export class Client<
|
|||
// Only validate structured content if present (not when there's an error)
|
||||
if (result.structuredContent) {
|
||||
try {
|
||||
// Validate the structured content (which is already an object) against the schema
|
||||
const isValid = validator(result.structuredContent);
|
||||
// Validate the structured content against the schema
|
||||
const validationResult = validator(result.structuredContent);
|
||||
|
||||
if (!isValid) {
|
||||
if (!validationResult.valid) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`Structured content does not match the tool's output schema: ${this._ajv.errorsText(validator.errors)}`
|
||||
`Structured content does not match the tool's output schema: ${validationResult.errorMessage}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -371,23 +402,26 @@ export class Client<
|
|||
return result;
|
||||
}
|
||||
|
||||
private cacheToolOutputSchemas(tools: Tool[]) {
|
||||
/**
|
||||
* Cache validators for tool output schemas.
|
||||
* Called after listTools() to pre-compile validators for better performance.
|
||||
*/
|
||||
private cacheToolOutputSchemas(tools: Tool[]): void {
|
||||
this._cachedToolOutputValidators.clear();
|
||||
|
||||
for (const tool of tools) {
|
||||
// If the tool has an outputSchema, create and cache the Ajv validator
|
||||
// If the tool has an outputSchema, create and cache the validator
|
||||
if (tool.outputSchema) {
|
||||
try {
|
||||
const validator = this._ajv.compile(tool.outputSchema);
|
||||
this._cachedToolOutputValidators.set(tool.name, validator);
|
||||
} catch {
|
||||
// Ignore schema compilation errors
|
||||
}
|
||||
const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType);
|
||||
this._cachedToolOutputValidators.set(tool.name, toolValidator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getToolOutputValidator(toolName: string): ValidateFunction | undefined {
|
||||
/**
|
||||
* Get cached validator for a tool
|
||||
*/
|
||||
private getToolOutputValidator(toolName: string): JsonSchemaValidator<unknown> | undefined {
|
||||
return this._cachedToolOutputValidators.get(toolName);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
ReadResourceResultSchema
|
||||
} from '../../types.js';
|
||||
import { getDisplayName } from '../../shared/metadataUtils.js';
|
||||
import Ajv from 'ajv';
|
||||
import { Ajv } from 'ajv';
|
||||
|
||||
// Create readline interface for user input
|
||||
const readline = createInterface({
|
||||
|
|
@ -377,7 +377,7 @@ async function connect(url?: string): Promise<void> {
|
|||
if (!isValid) {
|
||||
console.log('❌ Validation errors:');
|
||||
validate.errors?.forEach(error => {
|
||||
console.log(` - ${error.dataPath || 'root'}: ${error.message}`);
|
||||
console.log(` - ${error.instancePath || 'root'}: ${error.message}`);
|
||||
});
|
||||
|
||||
if (attempts < maxAttempts) {
|
||||
|
|
|
|||
476
src/examples/server/elicitationExample.ts
Normal file
476
src/examples/server/elicitationExample.ts
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
// Run with: npx tsx src/examples/server/elicitationExample.ts
|
||||
//
|
||||
// This example demonstrates how to use elicitation to collect structured user input
|
||||
// with JSON Schema validation via a local HTTP server with SSE streaming.
|
||||
// Elicitation allows servers to request user input through the client interface
|
||||
// with schema-based validation.
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import cors from 'cors';
|
||||
import express, { type Request, type Response } from 'express';
|
||||
import { McpServer } from '../../server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
|
||||
import { isInitializeRequest } from '../../types.js';
|
||||
|
||||
// Create MCP server - it will automatically use AjvJsonSchemaValidator with sensible defaults
|
||||
// The validator supports format validation (email, date, etc.) if ajv-formats is installed
|
||||
const mcpServer = new McpServer(
|
||||
{
|
||||
name: 'elicitation-example-server',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Example 1: Simple user registration tool
|
||||
* Collects username, email, and password from the user
|
||||
*/
|
||||
mcpServer.registerTool(
|
||||
'register_user',
|
||||
{
|
||||
description: 'Register a new user account by collecting their information',
|
||||
inputSchema: {}
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
// Request user information through elicitation
|
||||
const result = await mcpServer.server.elicitInput({
|
||||
message: 'Please provide your registration information:',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: {
|
||||
type: 'string',
|
||||
title: 'Username',
|
||||
description: 'Your desired username (3-20 characters)',
|
||||
minLength: 3,
|
||||
maxLength: 20
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
title: 'Email',
|
||||
description: 'Your email address',
|
||||
format: 'email'
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
title: 'Password',
|
||||
description: 'Your password (min 8 characters)',
|
||||
minLength: 8
|
||||
},
|
||||
newsletter: {
|
||||
type: 'boolean',
|
||||
title: 'Newsletter',
|
||||
description: 'Subscribe to newsletter?',
|
||||
default: false
|
||||
}
|
||||
},
|
||||
required: ['username', 'email', 'password']
|
||||
}
|
||||
});
|
||||
|
||||
// Handle the different possible actions
|
||||
if (result.action === 'accept' && result.content) {
|
||||
const { username, email, newsletter } = result.content as {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
newsletter?: boolean;
|
||||
};
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Registration successful!\n\nUsername: ${username}\nEmail: ${email}\nNewsletter: ${newsletter ? 'Yes' : 'No'}`
|
||||
}
|
||||
]
|
||||
};
|
||||
} else if (result.action === 'decline') {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Registration cancelled by user.'
|
||||
}
|
||||
]
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Registration was cancelled.'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Registration failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Example 2: Multi-step workflow with multiple elicitation requests
|
||||
* Demonstrates how to collect information in multiple steps
|
||||
*/
|
||||
mcpServer.registerTool(
|
||||
'create_event',
|
||||
{
|
||||
description: 'Create a calendar event by collecting event details',
|
||||
inputSchema: {}
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
// Step 1: Collect basic event information
|
||||
const basicInfo = await mcpServer.server.elicitInput({
|
||||
message: 'Step 1: Enter basic event information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: {
|
||||
type: 'string',
|
||||
title: 'Event Title',
|
||||
description: 'Name of the event',
|
||||
minLength: 1
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
title: 'Description',
|
||||
description: 'Event description (optional)'
|
||||
}
|
||||
},
|
||||
required: ['title']
|
||||
}
|
||||
});
|
||||
|
||||
if (basicInfo.action !== 'accept' || !basicInfo.content) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Event creation cancelled.' }]
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Collect date and time
|
||||
const dateTime = await mcpServer.server.elicitInput({
|
||||
message: 'Step 2: Enter date and time',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
date: {
|
||||
type: 'string',
|
||||
title: 'Date',
|
||||
description: 'Event date',
|
||||
format: 'date'
|
||||
},
|
||||
startTime: {
|
||||
type: 'string',
|
||||
title: 'Start Time',
|
||||
description: 'Event start time (HH:MM)',
|
||||
pattern: '^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$'
|
||||
},
|
||||
duration: {
|
||||
type: 'integer',
|
||||
title: 'Duration',
|
||||
description: 'Duration in minutes',
|
||||
minimum: 15,
|
||||
maximum: 480
|
||||
}
|
||||
},
|
||||
required: ['date', 'startTime', 'duration']
|
||||
}
|
||||
});
|
||||
|
||||
if (dateTime.action !== 'accept' || !dateTime.content) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Event creation cancelled.' }]
|
||||
};
|
||||
}
|
||||
|
||||
// Combine all collected information
|
||||
const event = {
|
||||
...basicInfo.content,
|
||||
...dateTime.content
|
||||
};
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Event created successfully!\n\n${JSON.stringify(event, null, 2)}`
|
||||
}
|
||||
]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Event creation failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Example 3: Collecting address information
|
||||
* Demonstrates validation with patterns and optional fields
|
||||
*/
|
||||
mcpServer.registerTool(
|
||||
'update_shipping_address',
|
||||
{
|
||||
description: 'Update shipping address with validation',
|
||||
inputSchema: {}
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
const result = await mcpServer.server.elicitInput({
|
||||
message: 'Please provide your shipping address:',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
title: 'Full Name',
|
||||
description: 'Recipient name',
|
||||
minLength: 1
|
||||
},
|
||||
street: {
|
||||
type: 'string',
|
||||
title: 'Street Address',
|
||||
minLength: 1
|
||||
},
|
||||
city: {
|
||||
type: 'string',
|
||||
title: 'City',
|
||||
minLength: 1
|
||||
},
|
||||
state: {
|
||||
type: 'string',
|
||||
title: 'State/Province',
|
||||
minLength: 2,
|
||||
maxLength: 2
|
||||
},
|
||||
zipCode: {
|
||||
type: 'string',
|
||||
title: 'ZIP/Postal Code',
|
||||
description: '5-digit ZIP code',
|
||||
pattern: '^[0-9]{5}$'
|
||||
},
|
||||
phone: {
|
||||
type: 'string',
|
||||
title: 'Phone Number (optional)',
|
||||
description: 'Contact phone number'
|
||||
}
|
||||
},
|
||||
required: ['name', 'street', 'city', 'state', 'zipCode']
|
||||
}
|
||||
});
|
||||
|
||||
if (result.action === 'accept' && result.content) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Address updated successfully!\n\n${JSON.stringify(result.content, null, 2)}`
|
||||
}
|
||||
]
|
||||
};
|
||||
} else if (result.action === 'decline') {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Address update cancelled by user.' }]
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Address update was cancelled.' }]
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Address update failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function main() {
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Allow CORS for all domains, expose the Mcp-Session-Id header
|
||||
app.use(
|
||||
cors({
|
||||
origin: '*',
|
||||
exposedHeaders: ['Mcp-Session-Id']
|
||||
})
|
||||
);
|
||||
|
||||
// Map to store transports by session ID
|
||||
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
|
||||
|
||||
// MCP POST endpoint
|
||||
const mcpPostHandler = async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (sessionId) {
|
||||
console.log(`Received MCP request for session: ${sessionId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
let transport: StreamableHTTPServerTransport;
|
||||
if (sessionId && transports[sessionId]) {
|
||||
// Reuse existing transport for this session
|
||||
transport = transports[sessionId];
|
||||
} else if (!sessionId && isInitializeRequest(req.body)) {
|
||||
// New initialization request - create new transport
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: sessionId => {
|
||||
// Store the transport by session ID when session is initialized
|
||||
console.log(`Session initialized with ID: ${sessionId}`);
|
||||
transports[sessionId] = transport;
|
||||
}
|
||||
});
|
||||
|
||||
// Set up onclose handler to clean up transport when closed
|
||||
transport.onclose = () => {
|
||||
const sid = transport.sessionId;
|
||||
if (sid && transports[sid]) {
|
||||
console.log(`Transport closed for session ${sid}, removing from transports map`);
|
||||
delete transports[sid];
|
||||
}
|
||||
};
|
||||
|
||||
// Connect the transport to the MCP server BEFORE handling the request
|
||||
await mcpServer.connect(transport);
|
||||
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
return;
|
||||
} else {
|
||||
// Invalid request - no session ID or not initialization request
|
||||
res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Bad Request: No valid session ID provided'
|
||||
},
|
||||
id: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle the request with existing transport
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
console.error('Error handling MCP request:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32603,
|
||||
message: 'Internal server error'
|
||||
},
|
||||
id: null
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
app.post('/mcp', mcpPostHandler);
|
||||
|
||||
// Handle GET requests for SSE streams
|
||||
const mcpGetHandler = async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (!sessionId || !transports[sessionId]) {
|
||||
res.status(400).send('Invalid or missing session ID');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Establishing SSE stream for session ${sessionId}`);
|
||||
const transport = transports[sessionId];
|
||||
await transport.handleRequest(req, res);
|
||||
};
|
||||
|
||||
app.get('/mcp', mcpGetHandler);
|
||||
|
||||
// Handle DELETE requests for session termination
|
||||
const mcpDeleteHandler = async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (!sessionId || !transports[sessionId]) {
|
||||
res.status(400).send('Invalid or missing session ID');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Received session termination request for session ${sessionId}`);
|
||||
|
||||
try {
|
||||
const transport = transports[sessionId];
|
||||
await transport.handleRequest(req, res);
|
||||
} catch (error) {
|
||||
console.error('Error handling session termination:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).send('Error processing session termination');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
app.delete('/mcp', mcpDeleteHandler);
|
||||
|
||||
// Start listening
|
||||
app.listen(PORT, error => {
|
||||
if (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Elicitation example server is running on http://localhost:${PORT}/mcp`);
|
||||
console.log('Available tools:');
|
||||
console.log(' - register_user: Collect user registration information');
|
||||
console.log(' - create_event: Multi-step event creation');
|
||||
console.log(' - update_shipping_address: Collect and validate address');
|
||||
console.log('\nConnect your MCP client to this server using the HTTP transport.');
|
||||
});
|
||||
|
||||
// Handle server shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Shutting down server...');
|
||||
|
||||
// Close all active transports to properly clean up resources
|
||||
for (const sessionId in transports) {
|
||||
try {
|
||||
console.log(`Closing transport for session ${sessionId}`);
|
||||
await transports[sessionId].close();
|
||||
delete transports[sessionId];
|
||||
} catch (error) {
|
||||
console.error(`Error closing transport for session ${sessionId}:`, error);
|
||||
}
|
||||
}
|
||||
console.log('Server shutdown complete');
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('Server error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
642
src/server/elicitation.test.ts
Normal file
642
src/server/elicitation.test.ts
Normal file
|
|
@ -0,0 +1,642 @@
|
|||
/**
|
||||
* Comprehensive elicitation flow tests with validator integration
|
||||
*
|
||||
* These tests verify the end-to-end elicitation flow from server requesting
|
||||
* input to client responding and validation of the response against schemas.
|
||||
*
|
||||
* Per the MCP spec, elicitation only supports object schemas, not primitives.
|
||||
*/
|
||||
|
||||
import { Client } from '../client/index.js';
|
||||
import { InMemoryTransport } from '../inMemory.js';
|
||||
import { ElicitRequestSchema } from '../types.js';
|
||||
import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js';
|
||||
import { CfWorkerJsonSchemaValidator } from '../validation/cfworker-provider.js';
|
||||
import { Server } from './index.js';
|
||||
|
||||
const ajvProvider = new AjvJsonSchemaValidator();
|
||||
const cfWorkerProvider = new CfWorkerJsonSchemaValidator();
|
||||
|
||||
describe('Elicitation Flow', () => {
|
||||
describe('with AJV validator', () => {
|
||||
testElicitationFlow(ajvProvider, 'AJV');
|
||||
});
|
||||
|
||||
describe('with CfWorker validator', () => {
|
||||
testElicitationFlow(cfWorkerProvider, 'CfWorker');
|
||||
});
|
||||
});
|
||||
|
||||
function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWorkerProvider, validatorName: string) {
|
||||
test(`${validatorName}: should elicit simple object with string field`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { name: 'John Doe' }
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
message: 'What is your name?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { name: 'John Doe' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should elicit object with integer field`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { age: 42 }
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
message: 'What is your age?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
age: { type: 'integer', minimum: 0, maximum: 150 }
|
||||
},
|
||||
required: ['age']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { age: 42 }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should elicit object with boolean field`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { agree: true }
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
message: 'Do you agree?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
agree: { type: 'boolean' }
|
||||
},
|
||||
required: ['agree']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { agree: true }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should elicit complex object with multiple fields`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
const userData = {
|
||||
name: 'Jane Smith',
|
||||
email: 'jane@example.com',
|
||||
age: 28,
|
||||
street: '123 Main St',
|
||||
city: 'San Francisco',
|
||||
zipCode: '94105',
|
||||
newsletter: true,
|
||||
notifications: false
|
||||
};
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: userData
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 },
|
||||
email: { type: 'string', format: 'email' },
|
||||
age: { type: 'integer', minimum: 0, maximum: 150 },
|
||||
street: { type: 'string' },
|
||||
city: { type: 'string' },
|
||||
zipCode: { type: 'string', pattern: '^[0-9]{5}$' },
|
||||
newsletter: { type: 'boolean' },
|
||||
notifications: { type: 'boolean' }
|
||||
},
|
||||
required: ['name', 'email', 'age', 'street', 'city', 'zipCode']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: userData
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid object (missing required field)`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
email: 'user@example.com'
|
||||
// Missing required 'name' field
|
||||
}
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
email: { type: 'string' }
|
||||
},
|
||||
required: ['name', 'email']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid field type`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
name: 'John Doe',
|
||||
age: 'thirty' // Wrong type - should be integer
|
||||
}
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
age: { type: 'integer' }
|
||||
},
|
||||
required: ['name', 'age']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid string (too short)`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { name: '' } // Too short
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'What is your name?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid integer (out of range)`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { age: 200 } // Too high
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'What is your age?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
age: { type: 'integer', minimum: 0, maximum: 150 }
|
||||
},
|
||||
required: ['age']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid pattern`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { zipCode: 'ABC123' } // Doesn't match pattern
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'Enter a 5-digit zip code',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
zipCode: { type: 'string', pattern: '^[0-9]{5}$' }
|
||||
},
|
||||
required: ['zipCode']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should allow decline action without validation`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'decline'
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'decline'
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should allow cancel action without validation`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'cancel'
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'cancel'
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should handle multiple sequential elicitation requests`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
let requestCount = 0;
|
||||
client.setRequestHandler(ElicitRequestSchema, request => {
|
||||
requestCount++;
|
||||
if (request.params.message.includes('name')) {
|
||||
return { action: 'accept', content: { name: 'Alice' } };
|
||||
} else if (request.params.message.includes('age')) {
|
||||
return { action: 'accept', content: { age: 30 } };
|
||||
} else if (request.params.message.includes('city')) {
|
||||
return { action: 'accept', content: { city: 'New York' } };
|
||||
}
|
||||
return { action: 'decline' };
|
||||
});
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const nameResult = await server.elicitInput({
|
||||
message: 'What is your name?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string', minLength: 1 } },
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
const ageResult = await server.elicitInput({
|
||||
message: 'What is your age?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: { age: { type: 'integer', minimum: 0 } },
|
||||
required: ['age']
|
||||
}
|
||||
});
|
||||
|
||||
const cityResult = await server.elicitInput({
|
||||
message: 'What is your city?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: { city: { type: 'string', minLength: 1 } },
|
||||
required: ['city']
|
||||
}
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(3);
|
||||
expect(nameResult).toEqual({
|
||||
action: 'accept',
|
||||
content: { name: 'Alice' }
|
||||
});
|
||||
expect(ageResult).toEqual({ action: 'accept', content: { age: 30 } });
|
||||
expect(cityResult).toEqual({
|
||||
action: 'accept',
|
||||
content: { city: 'New York' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should validate with optional fields present`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { name: 'John', nickname: 'Johnny' }
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
message: 'Enter your name',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 },
|
||||
nickname: { type: 'string' }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { name: 'John', nickname: 'Johnny' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should validate with optional fields absent`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { name: 'John' }
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
message: 'Enter your name',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 },
|
||||
nickname: { type: 'string' }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { name: 'John' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should validate email format`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { email: 'user@example.com' }
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
message: 'Enter your email',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' }
|
||||
},
|
||||
required: ['email']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { email: 'user@example.com' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid email format`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { email: 'not-an-email' }
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'Enter your email',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' }
|
||||
},
|
||||
required: ['email']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,26 +1,24 @@
|
|||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable no-constant-binary-expression */
|
||||
/* eslint-disable @typescript-eslint/no-unused-expressions */
|
||||
import { Server } from './index.js';
|
||||
import { z } from 'zod';
|
||||
import { Client } from '../client/index.js';
|
||||
import { InMemoryTransport } from '../inMemory.js';
|
||||
import type { Transport } from '../shared/transport.js';
|
||||
import {
|
||||
RequestSchema,
|
||||
NotificationSchema,
|
||||
ResultSchema,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
SUPPORTED_PROTOCOL_VERSIONS,
|
||||
CreateMessageRequestSchema,
|
||||
ElicitRequestSchema,
|
||||
ErrorCode,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
ListPromptsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
type LoggingMessageNotification,
|
||||
NotificationSchema,
|
||||
RequestSchema,
|
||||
ResultSchema,
|
||||
SetLevelRequestSchema,
|
||||
ErrorCode,
|
||||
LoggingMessageNotification
|
||||
SUPPORTED_PROTOCOL_VERSIONS
|
||||
} from '../types.js';
|
||||
import { Transport } from '../shared/transport.js';
|
||||
import { InMemoryTransport } from '../inMemory.js';
|
||||
import { Client } from '../client/index.js';
|
||||
import { Server } from './index.js';
|
||||
|
||||
test('should accept latest protocol version', async () => {
|
||||
let sendPromiseResolve: (value: unknown) => void;
|
||||
|
|
@ -235,7 +233,7 @@ test('should respect client capabilities', async () => {
|
|||
);
|
||||
|
||||
// Implement request handler for sampling/createMessage
|
||||
client.setRequestHandler(CreateMessageRequestSchema, async request => {
|
||||
client.setRequestHandler(CreateMessageRequestSchema, async _request => {
|
||||
// Mock implementation of createMessage
|
||||
return {
|
||||
model: 'test-model',
|
||||
|
|
@ -377,7 +375,7 @@ test('should validate elicitation response against requested schema', async () =
|
|||
);
|
||||
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, request => ({
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
name: 'John Doe',
|
||||
|
|
@ -454,7 +452,7 @@ test('should reject elicitation response with invalid data', async () => {
|
|||
);
|
||||
|
||||
// Set up client to return invalid response (missing required field, invalid age)
|
||||
client.setRequestHandler(ElicitRequestSchema, request => ({
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
email: '', // Invalid - too short
|
||||
|
|
@ -523,7 +521,7 @@ test('should allow elicitation reject and cancel without validation', async () =
|
|||
);
|
||||
|
||||
let requestCount = 0;
|
||||
client.setRequestHandler(ElicitRequestSchema, request => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => {
|
||||
requestCount++;
|
||||
if (requestCount === 1) {
|
||||
return { action: 'decline' };
|
||||
|
|
@ -579,7 +577,7 @@ test('should respect server notification capabilities', async () => {
|
|||
}
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const [_clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await server.connect(serverTransport);
|
||||
|
||||
|
|
@ -685,7 +683,7 @@ test('should typecheck', () => {
|
|||
);
|
||||
|
||||
// Typecheck that only valid weather requests/notifications/results are allowed
|
||||
weatherServer.setRequestHandler(GetWeatherRequestSchema, request => {
|
||||
weatherServer.setRequestHandler(GetWeatherRequestSchema, _request => {
|
||||
return {
|
||||
temperature: 72,
|
||||
conditions: 'sunny'
|
||||
|
|
@ -723,7 +721,7 @@ test('should handle server cancelling a request', async () => {
|
|||
);
|
||||
|
||||
// Set up client to delay responding to createMessage
|
||||
client.setRequestHandler(CreateMessageRequestSchema, async (_request, extra) => {
|
||||
client.setRequestHandler(CreateMessageRequestSchema, async (_request, _extra) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
return {
|
||||
model: 'test',
|
||||
|
|
|
|||
|
|
@ -1,37 +1,38 @@
|
|||
import { mergeCapabilities, Protocol, ProtocolOptions, RequestOptions } from '../shared/protocol.js';
|
||||
import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js';
|
||||
import {
|
||||
ClientCapabilities,
|
||||
CreateMessageRequest,
|
||||
type ClientCapabilities,
|
||||
type CreateMessageRequest,
|
||||
CreateMessageResultSchema,
|
||||
ElicitRequest,
|
||||
ElicitResult,
|
||||
type ElicitRequest,
|
||||
type ElicitResult,
|
||||
ElicitResultSchema,
|
||||
EmptyResultSchema,
|
||||
Implementation,
|
||||
InitializedNotificationSchema,
|
||||
InitializeRequest,
|
||||
InitializeRequestSchema,
|
||||
InitializeResult,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
ListRootsRequest,
|
||||
ListRootsResultSchema,
|
||||
LoggingMessageNotification,
|
||||
McpError,
|
||||
ErrorCode,
|
||||
Notification,
|
||||
Request,
|
||||
ResourceUpdatedNotification,
|
||||
Result,
|
||||
ServerCapabilities,
|
||||
ServerNotification,
|
||||
ServerRequest,
|
||||
ServerResult,
|
||||
SUPPORTED_PROTOCOL_VERSIONS,
|
||||
LoggingLevel,
|
||||
type Implementation,
|
||||
InitializedNotificationSchema,
|
||||
type InitializeRequest,
|
||||
InitializeRequestSchema,
|
||||
type InitializeResult,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
type ListRootsRequest,
|
||||
ListRootsResultSchema,
|
||||
type LoggingLevel,
|
||||
LoggingLevelSchema,
|
||||
type LoggingMessageNotification,
|
||||
McpError,
|
||||
type Notification,
|
||||
type Request,
|
||||
type ResourceUpdatedNotification,
|
||||
type Result,
|
||||
type ServerCapabilities,
|
||||
type ServerNotification,
|
||||
type ServerRequest,
|
||||
type ServerResult,
|
||||
SetLevelRequestSchema,
|
||||
LoggingLevelSchema
|
||||
SUPPORTED_PROTOCOL_VERSIONS
|
||||
} from '../types.js';
|
||||
import Ajv from 'ajv';
|
||||
import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js';
|
||||
import type { JsonSchemaType, jsonSchemaValidator } from '../validation/types.js';
|
||||
|
||||
export type ServerOptions = ProtocolOptions & {
|
||||
/**
|
||||
|
|
@ -43,6 +44,37 @@ export type ServerOptions = ProtocolOptions & {
|
|||
* Optional instructions describing how to use the server and its features.
|
||||
*/
|
||||
instructions?: string;
|
||||
|
||||
/**
|
||||
* JSON Schema validator for elicitation response validation.
|
||||
*
|
||||
* The validator is used to validate user input returned from elicitation
|
||||
* requests against the requested schema.
|
||||
*
|
||||
* @default AjvJsonSchemaValidator
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // ajv (default)
|
||||
* const server = new Server(
|
||||
* { name: 'my-server', version: '1.0.0' },
|
||||
* {
|
||||
* capabilities: {}
|
||||
* jsonSchemaValidator: new AjvJsonSchemaValidator()
|
||||
* }
|
||||
* );
|
||||
*
|
||||
* // @cfworker/json-schema
|
||||
* const server = new Server(
|
||||
* { name: 'my-server', version: '1.0.0' },
|
||||
* {
|
||||
* capabilities: {},
|
||||
* jsonSchemaValidator: new CfWorkerJsonSchemaValidator()
|
||||
* }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
jsonSchemaValidator?: jsonSchemaValidator;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -79,6 +111,7 @@ export class Server<
|
|||
private _clientVersion?: Implementation;
|
||||
private _capabilities: ServerCapabilities;
|
||||
private _instructions?: string;
|
||||
private _jsonSchemaValidator: jsonSchemaValidator;
|
||||
|
||||
/**
|
||||
* Callback for when initialization has fully completed (i.e., the client has sent an `initialized` notification).
|
||||
|
|
@ -95,6 +128,7 @@ export class Server<
|
|||
super(options);
|
||||
this._capabilities = options?.capabilities ?? {};
|
||||
this._instructions = options?.instructions;
|
||||
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator();
|
||||
|
||||
this.setRequestHandler(InitializeRequestSchema, request => this._oninitialize(request));
|
||||
this.setNotificationHandler(InitializedNotificationSchema, () => this.oninitialized?.());
|
||||
|
|
@ -289,24 +323,25 @@ export class Server<
|
|||
const result = await this.request({ method: 'elicitation/create', params }, ElicitResultSchema, options);
|
||||
|
||||
// Validate the response content against the requested schema if action is "accept"
|
||||
if (result.action === 'accept' && result.content) {
|
||||
if (result.action === 'accept' && result.content && params.requestedSchema) {
|
||||
try {
|
||||
const ajv = new Ajv();
|
||||
const validator = this._jsonSchemaValidator.getValidator(params.requestedSchema as JsonSchemaType);
|
||||
const validationResult = validator(result.content);
|
||||
|
||||
const validate = ajv.compile(params.requestedSchema);
|
||||
const isValid = validate(result.content);
|
||||
|
||||
if (!isValid) {
|
||||
if (!validationResult.valid) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`Elicitation response content does not match requested schema: ${ajv.errorsText(validate.errors)}`
|
||||
`Elicitation response content does not match requested schema: ${validationResult.errorMessage}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof McpError) {
|
||||
throw error;
|
||||
}
|
||||
throw new McpError(ErrorCode.InternalError, `Error validating elicitation response: ${error}`);
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
`Error validating elicitation response: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,24 @@
|
|||
import { McpServer } from './mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { Client } from '../client/index.js';
|
||||
import { InMemoryTransport } from '../inMemory.js';
|
||||
import { z } from 'zod';
|
||||
import { getDisplayName } from '../shared/metadataUtils.js';
|
||||
import { UriTemplate } from '../shared/uriTemplate.js';
|
||||
import {
|
||||
ListToolsResultSchema,
|
||||
CallToolResultSchema,
|
||||
CompleteResultSchema,
|
||||
ElicitRequestSchema,
|
||||
GetPromptResultSchema,
|
||||
ListPromptsResultSchema,
|
||||
ListResourcesResultSchema,
|
||||
ListResourceTemplatesResultSchema,
|
||||
ReadResourceResultSchema,
|
||||
ListPromptsResultSchema,
|
||||
GetPromptResultSchema,
|
||||
CompleteResultSchema,
|
||||
ListToolsResultSchema,
|
||||
LoggingMessageNotificationSchema,
|
||||
Notification,
|
||||
TextContent,
|
||||
ElicitRequestSchema
|
||||
type Notification,
|
||||
ReadResourceResultSchema,
|
||||
type TextContent
|
||||
} from '../types.js';
|
||||
import { ResourceTemplate } from './mcp.js';
|
||||
import { completable } from './completable.js';
|
||||
import { UriTemplate } from '../shared/uriTemplate.js';
|
||||
import { getDisplayName } from '../shared/metadataUtils.js';
|
||||
import { McpServer, ResourceTemplate } from './mcp.js';
|
||||
|
||||
describe('McpServer', () => {
|
||||
/***
|
||||
|
|
@ -112,11 +111,22 @@ describe('McpServer', () => {
|
|||
}
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text' as const, text: `Operation completed with ${steps} steps` }] };
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Operation completed with ${steps} steps`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const progressUpdates: Array<{ progress: number; total?: number; message?: string }> = [];
|
||||
const progressUpdates: Array<{
|
||||
progress: number;
|
||||
total?: number;
|
||||
message?: string;
|
||||
}> = [];
|
||||
|
||||
const client = new Client({
|
||||
name: 'test client',
|
||||
|
|
@ -677,9 +687,15 @@ describe('tool()', () => {
|
|||
|
||||
expect(result.tools).toHaveLength(2);
|
||||
expect(result.tools[0].name).toBe('test');
|
||||
expect(result.tools[0].annotations).toEqual({ title: 'Test Tool', readOnlyHint: true });
|
||||
expect(result.tools[0].annotations).toEqual({
|
||||
title: 'Test Tool',
|
||||
readOnlyHint: true
|
||||
});
|
||||
expect(result.tools[1].name).toBe('test (new api)');
|
||||
expect(result.tools[1].annotations).toEqual({ title: 'Test Tool', readOnlyHint: true });
|
||||
expect(result.tools[1].annotations).toEqual({
|
||||
title: 'Test Tool',
|
||||
readOnlyHint: true
|
||||
});
|
||||
});
|
||||
|
||||
/***
|
||||
|
|
@ -722,7 +738,10 @@ describe('tool()', () => {
|
|||
type: 'object',
|
||||
properties: { name: { type: 'string' } }
|
||||
});
|
||||
expect(result.tools[0].annotations).toEqual({ title: 'Test Tool', readOnlyHint: true });
|
||||
expect(result.tools[0].annotations).toEqual({
|
||||
title: 'Test Tool',
|
||||
readOnlyHint: true
|
||||
});
|
||||
expect(result.tools[1].name).toBe('test (new api)');
|
||||
expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema);
|
||||
expect(result.tools[1].annotations).toEqual(result.tools[0].annotations);
|
||||
|
|
@ -756,7 +775,11 @@ describe('tool()', () => {
|
|||
{
|
||||
description: 'A tool with everything',
|
||||
inputSchema: { name: z.string() },
|
||||
annotations: { title: 'Complete Test Tool', readOnlyHint: true, openWorldHint: false }
|
||||
annotations: {
|
||||
title: 'Complete Test Tool',
|
||||
readOnlyHint: true,
|
||||
openWorldHint: false
|
||||
}
|
||||
},
|
||||
async ({ name }) => ({
|
||||
content: [{ type: 'text', text: `Hello, ${name}!` }]
|
||||
|
|
@ -804,7 +827,11 @@ describe('tool()', () => {
|
|||
'test',
|
||||
'A tool with everything but empty params',
|
||||
{},
|
||||
{ title: 'Complete Test Tool with empty params', readOnlyHint: true, openWorldHint: false },
|
||||
{
|
||||
title: 'Complete Test Tool with empty params',
|
||||
readOnlyHint: true,
|
||||
openWorldHint: false
|
||||
},
|
||||
async () => ({
|
||||
content: [{ type: 'text', text: 'Test response' }]
|
||||
})
|
||||
|
|
@ -815,7 +842,11 @@ describe('tool()', () => {
|
|||
{
|
||||
description: 'A tool with everything but empty params',
|
||||
inputSchema: {},
|
||||
annotations: { title: 'Complete Test Tool with empty params', readOnlyHint: true, openWorldHint: false }
|
||||
annotations: {
|
||||
title: 'Complete Test Tool with empty params',
|
||||
readOnlyHint: true,
|
||||
openWorldHint: false
|
||||
}
|
||||
},
|
||||
async () => ({
|
||||
content: [{ type: 'text' as const, text: 'Test response' }]
|
||||
|
|
@ -1342,7 +1373,7 @@ describe('tool()', () => {
|
|||
|
||||
expect(receivedRequestId).toBeDefined();
|
||||
expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true);
|
||||
expect(result.content && result.content[0].text).toContain('Received request ID:');
|
||||
expect(result.content?.[0].text).toContain('Received request ID:');
|
||||
});
|
||||
|
||||
/***
|
||||
|
|
@ -1370,7 +1401,10 @@ describe('tool()', () => {
|
|||
});
|
||||
|
||||
mcpServer.tool('test-tool', async ({ sendNotification }) => {
|
||||
await sendNotification({ method: 'notifications/message', params: { level: 'debug', data: loggingMessage } });
|
||||
await sendNotification({
|
||||
method: 'notifications/message',
|
||||
params: { level: 'debug', data: loggingMessage }
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
|
|
|
|||
97
src/validation/ajv-provider.ts
Normal file
97
src/validation/ajv-provider.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* AJV-based JSON Schema validator provider
|
||||
*/
|
||||
|
||||
import { Ajv } from 'ajv';
|
||||
import _addFormats from 'ajv-formats';
|
||||
import type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } from './types.js';
|
||||
|
||||
function createDefaultAjvInstance(): Ajv {
|
||||
const ajv = new Ajv({
|
||||
strict: false,
|
||||
validateFormats: true,
|
||||
validateSchema: false,
|
||||
allErrors: true
|
||||
});
|
||||
|
||||
const addFormats = _addFormats as unknown as typeof _addFormats.default;
|
||||
addFormats(ajv);
|
||||
|
||||
return ajv;
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Use with default AJV instance (recommended)
|
||||
* import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv';
|
||||
* const validator = new AjvJsonSchemaValidator();
|
||||
*
|
||||
* // Use with custom AJV instance
|
||||
* import { Ajv } from 'ajv';
|
||||
* const ajv = new Ajv({ strict: true, allErrors: true });
|
||||
* const validator = new AjvJsonSchemaValidator(ajv);
|
||||
* ```
|
||||
*/
|
||||
export class AjvJsonSchemaValidator implements jsonSchemaValidator {
|
||||
private _ajv: Ajv;
|
||||
|
||||
/**
|
||||
* Create an AJV validator
|
||||
*
|
||||
* @param ajv - Optional pre-configured AJV instance. If not provided, a default instance will be created.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Use default configuration (recommended for most cases)
|
||||
* import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv';
|
||||
* const validator = new AjvJsonSchemaValidator();
|
||||
*
|
||||
* // Or provide custom AJV instance for advanced configuration
|
||||
* import { Ajv } from 'ajv';
|
||||
* import addFormats from 'ajv-formats';
|
||||
*
|
||||
* const ajv = new Ajv({ validateFormats: true });
|
||||
* addFormats(ajv);
|
||||
* const validator = new AjvJsonSchemaValidator(ajv);
|
||||
* ```
|
||||
*/
|
||||
constructor(ajv?: Ajv) {
|
||||
this._ajv = ajv ?? createDefaultAjvInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a validator for the given JSON Schema
|
||||
*
|
||||
* The validator is compiled once and can be reused multiple times.
|
||||
* If the schema has an $id, it will be cached by AJV automatically.
|
||||
*
|
||||
* @param schema - Standard JSON Schema object
|
||||
* @returns A validator function that validates input data
|
||||
*/
|
||||
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
|
||||
// Check if schema has $id and is already compiled/cached
|
||||
const ajvValidator =
|
||||
'$id' in schema && typeof schema.$id === 'string'
|
||||
? (this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema))
|
||||
: this._ajv.compile(schema);
|
||||
|
||||
return (input: unknown): JsonSchemaValidatorResult<T> => {
|
||||
const valid = ajvValidator(input);
|
||||
|
||||
if (valid) {
|
||||
return {
|
||||
valid: true,
|
||||
data: input as T,
|
||||
errorMessage: undefined
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
valid: false,
|
||||
data: undefined,
|
||||
errorMessage: this._ajv.errorsText(ajvValidator.errors)
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
78
src/validation/cfworker-provider.ts
Normal file
78
src/validation/cfworker-provider.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Cloudflare Worker-compatible JSON Schema validator provider
|
||||
*
|
||||
* This provider uses @cfworker/json-schema for validation without code generation,
|
||||
* making it compatible with edge runtimes like Cloudflare Workers that restrict
|
||||
* eval and new Function.
|
||||
*
|
||||
*/
|
||||
|
||||
import { type Schema, Validator } from '@cfworker/json-schema';
|
||||
import type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } from './types.js';
|
||||
|
||||
/**
|
||||
* JSON Schema draft version supported by @cfworker/json-schema
|
||||
*/
|
||||
export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12';
|
||||
|
||||
/**
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Use with default configuration (2020-12, shortcircuit)
|
||||
* const validator = new CfWorkerJsonSchemaValidator();
|
||||
*
|
||||
* // Use with custom configuration
|
||||
* const validator = new CfWorkerJsonSchemaValidator({
|
||||
* draft: '2020-12',
|
||||
* shortcircuit: false // Report all errors
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator {
|
||||
private shortcircuit: boolean;
|
||||
private draft: CfWorkerSchemaDraft;
|
||||
|
||||
/**
|
||||
* Create a validator
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @param options.shortcircuit - If true, stop validation after first error (default: true)
|
||||
* @param options.draft - JSON Schema draft version to use (default: '2020-12')
|
||||
*/
|
||||
constructor(options?: { shortcircuit?: boolean; draft?: CfWorkerSchemaDraft }) {
|
||||
this.shortcircuit = options?.shortcircuit ?? true;
|
||||
this.draft = options?.draft ?? '2020-12';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a validator for the given JSON Schema
|
||||
*
|
||||
* Unlike AJV, this validator is not cached internally
|
||||
*
|
||||
* @param schema - Standard JSON Schema object
|
||||
* @returns A validator function that validates input data
|
||||
*/
|
||||
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
|
||||
const cfSchema = schema as unknown as Schema;
|
||||
const validator = new Validator(cfSchema, this.draft, this.shortcircuit);
|
||||
|
||||
return (input: unknown): JsonSchemaValidatorResult<T> => {
|
||||
const result = validator.validate(input);
|
||||
|
||||
if (result.valid) {
|
||||
return {
|
||||
valid: true,
|
||||
data: input as T,
|
||||
errorMessage: undefined
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
valid: false,
|
||||
data: undefined,
|
||||
errorMessage: result.errors.map(err => `${err.instanceLocation}: ${err.error}`).join('; ')
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
30
src/validation/index.ts
Normal file
30
src/validation/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* JSON Schema validation
|
||||
*
|
||||
* This module provides configurable JSON Schema validation for the MCP SDK.
|
||||
* Choose a validator based on your runtime environment:
|
||||
*
|
||||
* - AjvJsonSchemaValidator: Best for Node.js (default, fastest)
|
||||
* Import from: @modelcontextprotocol/sdk/validation/ajv
|
||||
* Requires peer dependencies: ajv, ajv-formats
|
||||
*
|
||||
* - CfWorkerJsonSchemaValidator: Best for edge runtimes
|
||||
* Import from: @modelcontextprotocol/sdk/validation/cfworker
|
||||
* Requires peer dependency: @cfworker/json-schema
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // For Node.js with AJV
|
||||
* import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv';
|
||||
* const validator = new AjvJsonSchemaValidator();
|
||||
*
|
||||
* // For Cloudflare Workers
|
||||
* import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/cfworker';
|
||||
* const validator = new CfWorkerJsonSchemaValidator();
|
||||
* ```
|
||||
*
|
||||
* @module validation
|
||||
*/
|
||||
|
||||
// Core types only - implementations are exported via separate entry points
|
||||
export type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } from './types.js';
|
||||
52
src/validation/types.ts
Normal file
52
src/validation/types.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { Schema } from '@cfworker/json-schema';
|
||||
|
||||
/**
|
||||
* Result of a JSON Schema validation operation
|
||||
*/
|
||||
export type JsonSchemaValidatorResult<T> =
|
||||
| { valid: true; data: T; errorMessage: undefined }
|
||||
| { valid: false; data: undefined; errorMessage: string };
|
||||
|
||||
/**
|
||||
* A validator function that validates data against a JSON Schema
|
||||
*/
|
||||
export type JsonSchemaValidator<T> = (input: unknown) => JsonSchemaValidatorResult<T>;
|
||||
|
||||
/**
|
||||
* Provider interface for creating validators from JSON Schemas
|
||||
*
|
||||
* This is the main extension point for custom validator implementations.
|
||||
* Implementations should:
|
||||
* - Support JSON Schema Draft 2020-12 (or be compatible with it)
|
||||
* - Return validator functions that can be called multiple times
|
||||
* - Handle schema compilation/caching internally
|
||||
* - Provide clear error messages on validation failure
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class MyValidatorProvider implements jsonSchemaValidator {
|
||||
* getValidator<T>(schema: JsonSchemaType<T>): JsonSchemaValidator<T> {
|
||||
* // Compile/cache validator from schema
|
||||
* return (input: unknown) => {
|
||||
* // Validate input against schema
|
||||
* if (valid) {
|
||||
* return { valid: true, data: input as T, errorMessage: undefined };
|
||||
* } else {
|
||||
* return { valid: false, data: undefined, errorMessage: 'Error details' };
|
||||
* }
|
||||
* };
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface jsonSchemaValidator {
|
||||
/**
|
||||
* Create a validator for the given JSON Schema
|
||||
*
|
||||
* @param schema - Standard JSON Schema object
|
||||
* @returns A validator function that can be called multiple times
|
||||
*/
|
||||
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T>;
|
||||
}
|
||||
|
||||
export type JsonSchemaType = Schema;
|
||||
623
src/validation/validation.test.ts
Normal file
623
src/validation/validation.test.ts
Normal file
|
|
@ -0,0 +1,623 @@
|
|||
/**
|
||||
* Tests all validator providers with various JSON Schema 2020-12 features
|
||||
* Based on MCP specification for elicitation schemas:
|
||||
* https://modelcontextprotocol.io/specification/draft/client/elicitation.md
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { AjvJsonSchemaValidator } from './ajv-provider.js';
|
||||
import { CfWorkerJsonSchemaValidator } from './cfworker-provider.js';
|
||||
import type { JsonSchemaType } from './types.js';
|
||||
|
||||
// Test with both AJV and CfWorker validators
|
||||
// AJV validator will use default configuration with format validation enabled
|
||||
const validators = [
|
||||
{ name: 'AJV', provider: new AjvJsonSchemaValidator() },
|
||||
{ name: 'CfWorker', provider: new CfWorkerJsonSchemaValidator() }
|
||||
];
|
||||
|
||||
describe('JSON Schema Validators', () => {
|
||||
describe.each(validators)('$name Validator', ({ provider }) => {
|
||||
describe('String schemas', () => {
|
||||
it('validates basic string', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'string'
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
const validResult = validator('hello');
|
||||
expect(validResult.valid).toBe(true);
|
||||
expect(validResult.data).toBe('hello');
|
||||
|
||||
const invalidResult = validator(123);
|
||||
expect(invalidResult.valid).toBe(false);
|
||||
expect(invalidResult.errorMessage).toBeDefined();
|
||||
});
|
||||
|
||||
it('validates string with title and description', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'string',
|
||||
title: 'Name',
|
||||
description: "User's full name"
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
const result = validator('John Doe');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.data).toBe('John Doe');
|
||||
});
|
||||
|
||||
it('validates string with length constraints', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'string',
|
||||
minLength: 3,
|
||||
maxLength: 10
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator('abc').valid).toBe(true);
|
||||
expect(validator('abcdefghij').valid).toBe(true);
|
||||
expect(validator('ab').valid).toBe(false);
|
||||
expect(validator('abcdefghijk').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates email format', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'string',
|
||||
format: 'email'
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator('user@example.com').valid).toBe(true);
|
||||
expect(validator('invalid-email').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates URI format', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'string',
|
||||
format: 'uri'
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator('https://example.com').valid).toBe(true);
|
||||
expect(validator('not-a-uri').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates date-time format', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'string',
|
||||
format: 'date-time'
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator('2025-10-17T12:00:00Z').valid).toBe(true);
|
||||
expect(validator('not-a-date').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates string pattern', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'string',
|
||||
pattern: '^[A-Z]{3}$'
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator('ABC').valid).toBe(true);
|
||||
expect(validator('abc').valid).toBe(false);
|
||||
expect(validator('ABCD').valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Number schemas', () => {
|
||||
it('validates number type', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'number'
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator(42).valid).toBe(true);
|
||||
expect(validator(3.14).valid).toBe(true);
|
||||
expect(validator('42').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates integer type', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'integer'
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator(42).valid).toBe(true);
|
||||
expect(validator(3.14).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates number range', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
maximum: 100
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator(0).valid).toBe(true);
|
||||
expect(validator(50).valid).toBe(true);
|
||||
expect(validator(100).valid).toBe(true);
|
||||
expect(validator(-1).valid).toBe(false);
|
||||
expect(validator(101).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Boolean schemas', () => {
|
||||
it('validates boolean type', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'boolean'
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator(true).valid).toBe(true);
|
||||
expect(validator(false).valid).toBe(true);
|
||||
expect(validator('true').valid).toBe(false);
|
||||
expect(validator(1).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates boolean with default', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'boolean',
|
||||
default: false
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator(true).valid).toBe(true);
|
||||
expect(validator(false).valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enum schemas', () => {
|
||||
it('validates enum values', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
enum: ['red', 'green', 'blue']
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator('red').valid).toBe(true);
|
||||
expect(validator('green').valid).toBe(true);
|
||||
expect(validator('blue').valid).toBe(true);
|
||||
expect(validator('yellow').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates enum with mixed types', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
enum: ['option1', 42, true, null]
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator('option1').valid).toBe(true);
|
||||
expect(validator(42).valid).toBe(true);
|
||||
expect(validator(true).valid).toBe(true);
|
||||
expect(validator(null).valid).toBe(true);
|
||||
expect(validator('other').valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Object schemas', () => {
|
||||
it('validates simple object', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
age: { type: 'number' }
|
||||
},
|
||||
required: ['name']
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator({ name: 'John', age: 30 }).valid).toBe(true);
|
||||
expect(validator({ name: 'John' }).valid).toBe(true);
|
||||
expect(validator({ age: 30 }).valid).toBe(false);
|
||||
expect(validator({}).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates nested objects', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
email: { type: 'string', format: 'email' }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
},
|
||||
required: ['user']
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(
|
||||
validator({
|
||||
user: { name: 'John', email: 'john@example.com' }
|
||||
}).valid
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
validator({
|
||||
user: { name: 'John' }
|
||||
}).valid
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
validator({
|
||||
user: { email: 'john@example.com' }
|
||||
}).valid
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('validates object with additionalProperties: false', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' }
|
||||
},
|
||||
additionalProperties: false
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator({ name: 'John' }).valid).toBe(true);
|
||||
expect(validator({ name: 'John', extra: 'field' }).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Array schemas', () => {
|
||||
it('validates array of strings', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator(['a', 'b', 'c']).valid).toBe(true);
|
||||
expect(validator([]).valid).toBe(true);
|
||||
expect(validator(['a', 1, 'c']).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates array length constraints', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
minItems: 1,
|
||||
maxItems: 3
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator([1]).valid).toBe(true);
|
||||
expect(validator([1, 2, 3]).valid).toBe(true);
|
||||
expect(validator([]).valid).toBe(false);
|
||||
expect(validator([1, 2, 3, 4]).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates array with unique items', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
uniqueItems: true
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator([1, 2, 3]).valid).toBe(true);
|
||||
expect(validator([1, 2, 2, 3]).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON Schema 2020-12 features', () => {
|
||||
it('validates schema with $schema field', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
type: 'string'
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator('test').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('validates schema with $id field', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
$id: 'https://example.com/schemas/test',
|
||||
type: 'number'
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator(42).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('validates with allOf', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
allOf: [
|
||||
{ type: 'object', properties: { name: { type: 'string' } } },
|
||||
{ type: 'object', properties: { age: { type: 'number' } } }
|
||||
]
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator({ name: 'John', age: 30 }).valid).toBe(true);
|
||||
expect(validator({ name: 'John' }).valid).toBe(true);
|
||||
expect(validator({ name: 123 }).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates with anyOf', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
anyOf: [{ type: 'string' }, { type: 'number' }]
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator('test').valid).toBe(true);
|
||||
expect(validator(42).valid).toBe(true);
|
||||
expect(validator(true).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates with oneOf', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
oneOf: [
|
||||
{ type: 'string', minLength: 5 },
|
||||
{ type: 'string', maxLength: 3 }
|
||||
]
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator('ab').valid).toBe(true); // Matches second only
|
||||
expect(validator('hello').valid).toBe(true); // Matches first only
|
||||
expect(validator('abcd').valid).toBe(false); // Matches neither
|
||||
});
|
||||
|
||||
it('validates with not', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
not: { type: 'null' }
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator('test').valid).toBe(true);
|
||||
expect(validator(42).valid).toBe(true);
|
||||
expect(validator(null).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates with const', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
const: 'specific-value'
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(validator('specific-value').valid).toBe(true);
|
||||
expect(validator('other-value').valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex real-world schemas', () => {
|
||||
it('validates user registration form', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: {
|
||||
type: 'string',
|
||||
minLength: 3,
|
||||
maxLength: 20,
|
||||
pattern: '^[a-zA-Z0-9_]+$'
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
format: 'email'
|
||||
},
|
||||
age: {
|
||||
type: 'integer',
|
||||
minimum: 18,
|
||||
maximum: 120
|
||||
},
|
||||
newsletter: {
|
||||
type: 'boolean',
|
||||
default: false
|
||||
}
|
||||
},
|
||||
required: ['username', 'email']
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(
|
||||
validator({
|
||||
username: 'john_doe',
|
||||
email: 'john@example.com',
|
||||
age: 25,
|
||||
newsletter: true
|
||||
}).valid
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
validator({
|
||||
username: 'john_doe',
|
||||
email: 'john@example.com'
|
||||
}).valid
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
validator({
|
||||
username: 'ab', // Too short
|
||||
email: 'john@example.com'
|
||||
}).valid
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
validator({
|
||||
username: 'john_doe',
|
||||
email: 'invalid-email'
|
||||
}).valid
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('validates API response with nested structure', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['success', 'error', 'pending']
|
||||
},
|
||||
data: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
items: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
quantity: { type: 'integer', minimum: 1 }
|
||||
},
|
||||
required: ['name', 'quantity']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['id', 'items']
|
||||
},
|
||||
timestamp: {
|
||||
type: 'string',
|
||||
format: 'date-time'
|
||||
}
|
||||
},
|
||||
required: ['status', 'data']
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
expect(
|
||||
validator({
|
||||
status: 'success',
|
||||
data: {
|
||||
id: '123',
|
||||
items: [
|
||||
{ name: 'Item 1', quantity: 5 },
|
||||
{ name: 'Item 2', quantity: 3 }
|
||||
]
|
||||
},
|
||||
timestamp: '2025-10-17T12:00:00Z'
|
||||
}).valid
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
validator({
|
||||
status: 'invalid-status',
|
||||
data: { id: '123', items: [] }
|
||||
}).valid
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error messages', () => {
|
||||
it('provides helpful error message on validation failure', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' }
|
||||
},
|
||||
required: ['name']
|
||||
};
|
||||
const validator = provider.getValidator(schema);
|
||||
|
||||
const result = validator({});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errorMessage).toBeDefined();
|
||||
expect(result.errorMessage).toBeTruthy();
|
||||
expect(typeof result.errorMessage).toBe('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Missing dependencies', () => {
|
||||
describe('AJV not installed but CfWorker is', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.unmock('ajv');
|
||||
jest.unmock('ajv-formats');
|
||||
});
|
||||
|
||||
it('should throw error when trying to import ajv-provider without ajv', async () => {
|
||||
// Mock ajv as not installed
|
||||
jest.doMock('ajv', () => {
|
||||
throw new Error("Cannot find module 'ajv'");
|
||||
});
|
||||
|
||||
jest.doMock('ajv-formats', () => {
|
||||
throw new Error("Cannot find module 'ajv-formats'");
|
||||
});
|
||||
|
||||
// Attempting to import ajv-provider should fail
|
||||
await expect(import('./ajv-provider.js')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should be able to import cfworker-provider when ajv is missing', async () => {
|
||||
// Mock ajv as not installed
|
||||
jest.doMock('ajv', () => {
|
||||
throw new Error("Cannot find module 'ajv'");
|
||||
});
|
||||
|
||||
jest.doMock('ajv-formats', () => {
|
||||
throw new Error("Cannot find module 'ajv-formats'");
|
||||
});
|
||||
|
||||
// But cfworker-provider should import successfully
|
||||
const cfworkerModule = await import('./cfworker-provider.js');
|
||||
expect(cfworkerModule.CfWorkerJsonSchemaValidator).toBeDefined();
|
||||
|
||||
// And should work correctly
|
||||
const validator = new cfworkerModule.CfWorkerJsonSchemaValidator();
|
||||
const schema: JsonSchemaType = { type: 'string' };
|
||||
const validatorFn = validator.getValidator(schema);
|
||||
expect(validatorFn('test').valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CfWorker not installed but AJV is', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.unmock('@cfworker/json-schema');
|
||||
});
|
||||
|
||||
it('should throw error when trying to import cfworker-provider without @cfworker/json-schema', async () => {
|
||||
// Mock @cfworker/json-schema as not installed
|
||||
jest.doMock('@cfworker/json-schema', () => {
|
||||
throw new Error("Cannot find module '@cfworker/json-schema'");
|
||||
});
|
||||
|
||||
// Attempting to import cfworker-provider should fail
|
||||
await expect(import('./cfworker-provider.js')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should be able to import ajv-provider when @cfworker/json-schema is missing', async () => {
|
||||
// Mock @cfworker/json-schema as not installed
|
||||
jest.doMock('@cfworker/json-schema', () => {
|
||||
throw new Error("Cannot find module '@cfworker/json-schema'");
|
||||
});
|
||||
|
||||
// But ajv-provider should import successfully
|
||||
const ajvModule = await import('./ajv-provider.js');
|
||||
expect(ajvModule.AjvJsonSchemaValidator).toBeDefined();
|
||||
|
||||
// And should work correctly
|
||||
const validator = new ajvModule.AjvJsonSchemaValidator();
|
||||
const schema: JsonSchemaType = { type: 'string' };
|
||||
const validatorFn = validator.getValidator(schema);
|
||||
expect(validatorFn('test').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should document that @cfworker/json-schema is required', () => {
|
||||
const cfworkerProviderPath = join(__dirname, 'cfworker-provider.ts');
|
||||
const content = readFileSync(cfworkerProviderPath, 'utf-8');
|
||||
|
||||
expect(content).toContain('@cfworker/json-schema');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue