From 19b1a74c9996f47946ae1bb35f55853af7ed7090 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Tue, 3 Feb 2026 16:49:08 +0100 Subject: [PATCH] feat(core): add draft-2020-12 JSON Schema support with lenient fallback (#15060) Co-authored-by: A.K.M. Adib Co-authored-by: Jack Wotherspoon --- .../core/src/utils/schemaValidator.test.ts | 89 +++++++++++++++ packages/core/src/utils/schemaValidator.ts | 105 +++++++++++++++--- 2 files changed, 176 insertions(+), 18 deletions(-) diff --git a/packages/core/src/utils/schemaValidator.test.ts b/packages/core/src/utils/schemaValidator.test.ts index ecd10321d2..6673c41417 100644 --- a/packages/core/src/utils/schemaValidator.test.ts +++ b/packages/core/src/utils/schemaValidator.test.ts @@ -122,4 +122,93 @@ describe('SchemaValidator', () => { }; expect(SchemaValidator.validate(schema, params)).not.toBeNull(); }); + + it('allows schemas with draft-07 $schema property', () => { + const schema = { + type: 'object', + properties: { name: { type: 'string' } }, + $schema: 'http://json-schema.org/draft-07/schema#', + }; + const params = { name: 'test' }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + }); + + it('allows schemas with unrecognized $schema versions (lenient fallback)', () => { + // Future-proof: any unrecognized schema version should skip validation + // with a warning rather than failing + const schema = { + type: 'object', + properties: { name: { type: 'string' } }, + $schema: 'https://json-schema.org/draft/2030-99/schema', + }; + const params = { name: 'test' }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + }); + + describe('JSON Schema draft-2020-12 support', () => { + it('validates params against draft-2020-12 schema', () => { + const schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + message: { + type: 'string', + }, + }, + required: ['message'], + }; + + // Valid data should pass + expect(SchemaValidator.validate(schema, { message: 'hello' })).toBeNull(); + // Invalid data should fail (proves validation actually works) + expect(SchemaValidator.validate(schema, { message: 123 })).not.toBeNull(); + }); + + it('validates draft-2020-12 schema with prefixItems', () => { + // prefixItems is a draft-2020-12 feature (replaces tuple validation) + const schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + coords: { + type: 'array', + prefixItems: [{ type: 'number' }, { type: 'number' }], + items: false, + }, + }, + }; + + // Valid: exactly 2 numbers + expect(SchemaValidator.validate(schema, { coords: [1, 2] })).toBeNull(); + // Invalid: 3 items when items: false + expect( + SchemaValidator.validate(schema, { coords: [1, 2, 3] }), + ).not.toBeNull(); + }); + + it('validates draft-2020-12 schema with $defs', () => { + // draft-2020-12 uses $defs instead of definitions + const schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + $defs: { + ChatRole: { + type: 'string', + enum: ['System', 'User', 'Assistant'], + }, + }, + properties: { + role: { $ref: '#/$defs/ChatRole' }, + }, + required: ['role'], + }; + + // Valid enum value + expect(SchemaValidator.validate(schema, { role: 'User' })).toBeNull(); + // Invalid enum value (proves validation works) + expect( + SchemaValidator.validate(schema, { role: 'InvalidRole' }), + ).not.toBeNull(); + }); + }); }); diff --git a/packages/core/src/utils/schemaValidator.ts b/packages/core/src/utils/schemaValidator.ts index ec3621aed9..3bbdbe9e92 100644 --- a/packages/core/src/utils/schemaValidator.ts +++ b/packages/core/src/utils/schemaValidator.ts @@ -4,29 +4,62 @@ * SPDX-License-Identifier: Apache-2.0 */ -import AjvPkg, { type AnySchema } from 'ajv'; +import AjvPkg, { type AnySchema, type Ajv } from 'ajv'; +// Ajv2020 is the documented way to use draft-2020-12: https://ajv.js.org/json-schema.html#draft-2020-12 +// eslint-disable-next-line import/no-internal-modules +import Ajv2020Pkg from 'ajv/dist/2020.js'; import * as addFormats from 'ajv-formats'; +import { debugLogger } from './debugLogger.js'; + // Ajv's ESM/CJS interop: use 'any' for compatibility as recommended by Ajv docs // eslint-disable-next-line @typescript-eslint/no-explicit-any const AjvClass = (AjvPkg as any).default || AjvPkg; -const ajValidator = new AjvClass( +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const Ajv2020Class = (Ajv2020Pkg as any).default || Ajv2020Pkg; + +const ajvOptions = { // See: https://ajv.js.org/options.html#strict-mode-options - { - // strictSchema defaults to true and prevents use of JSON schemas that - // include unrecognized keywords. The JSON schema spec specifically allows - // for the use of non-standard keywords and the spec-compliant behavior - // is to ignore those keywords. Note that setting this to false also - // allows use of non-standard or custom formats (the unknown format value - // will be logged but the schema will still be considered valid). - strictSchema: false, - }, -); + // strictSchema defaults to true and prevents use of JSON schemas that + // include unrecognized keywords. The JSON schema spec specifically allows + // for the use of non-standard keywords and the spec-compliant behavior + // is to ignore those keywords. Note that setting this to false also + // allows use of non-standard or custom formats (the unknown format value + // will be logged but the schema will still be considered valid). + strictSchema: false, +}; + +// Draft-07 validator (default) +const ajvDefault: Ajv = new AjvClass(ajvOptions); + +// Draft-2020-12 validator for MCP servers using rmcp +const ajv2020: Ajv = new Ajv2020Class(ajvOptions); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const addFormatsFunc = (addFormats as any).default || addFormats; -addFormatsFunc(ajValidator); +addFormatsFunc(ajvDefault); +addFormatsFunc(ajv2020); + +// Canonical draft-2020-12 meta-schema URI (used by rmcp MCP servers) +const DRAFT_2020_12_SCHEMA = 'https://json-schema.org/draft/2020-12/schema'; /** - * Simple utility to validate objects against JSON Schemas + * Returns the appropriate validator based on schema's $schema field. + */ +function getValidator(schema: AnySchema): Ajv { + if ( + typeof schema === 'object' && + schema !== null && + '$schema' in schema && + schema.$schema === DRAFT_2020_12_SCHEMA + ) { + return ajv2020; + } + return ajvDefault; +} + +/** + * Simple utility to validate objects against JSON Schemas. + * Supports both draft-07 (default) and draft-2020-12 schemas. */ export class SchemaValidator { /** @@ -40,10 +73,33 @@ export class SchemaValidator { if (typeof data !== 'object' || data === null) { return 'Value of params must be an object'; } - const validate = ajValidator.compile(schema); + + const anySchema = schema as AnySchema; + const validator = getValidator(anySchema); + + // Try to compile and validate; skip validation if schema can't be compiled. + // This handles schemas using JSON Schema versions AJV doesn't support + // (e.g., draft-2019-09, future versions). + // This matches LenientJsonSchemaValidator behavior in mcp-client.ts. + let validate; + try { + validate = validator.compile(anySchema); + } catch (error) { + // Schema compilation failed (unsupported version, invalid $ref, etc.) + // Skip validation rather than blocking tool usage. + // This matches LenientJsonSchemaValidator behavior in mcp-client.ts. + debugLogger.warn( + `Failed to compile schema (${ + (schema as Record)?.['$schema'] ?? '' + }): ${error instanceof Error ? error.message : String(error)}. ` + + 'Skipping parameter validation.', + ); + return null; + } + const valid = validate(data); if (!valid && validate.errors) { - return ajValidator.errorsText(validate.errors, { dataVar: 'params' }); + return validator.errorsText(validate.errors, { dataVar: 'params' }); } return null; } @@ -56,7 +112,20 @@ export class SchemaValidator { if (!schema) { return null; } - const isValid = ajValidator.validateSchema(schema); - return isValid ? null : ajValidator.errorsText(ajValidator.errors); + const validator = getValidator(schema); + try { + const isValid = validator.validateSchema(schema); + return isValid ? null : validator.errorsText(validator.errors); + } catch (error) { + // Schema validation failed (unsupported version, etc.) + // Skip validation rather than blocking tool usage. + debugLogger.warn( + `Failed to validate schema (${ + (schema as Record)?.['$schema'] ?? '' + }): ${error instanceof Error ? error.message : String(error)}. ` + + 'Skipping schema validation.', + ); + return null; + } } }