From 3ae2f8f6718973e1a67360d99241d153bd4d8e46 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 12 Feb 2026 16:31:01 +0800 Subject: [PATCH] fix: support JSON Schema draft-2020-12 for MCP tools (fixes #1818) - Add Ajv2020 validator to support draft-2020-12 schemas used by playwright-mcp - Auto-select validator based on $schema field - Gracefully skip validation when schema compilation fails - Add comprehensive tests for JSON Schema version support Reference: gemini-cli implementation pattern --- .../core/src/utils/schemaValidator.test.ts | 85 +++++++++++++++++ packages/core/src/utils/schemaValidator.ts | 92 +++++++++++++++---- 2 files changed, 160 insertions(+), 17 deletions(-) diff --git a/packages/core/src/utils/schemaValidator.test.ts b/packages/core/src/utils/schemaValidator.test.ts index e662bcb7d..e882b983b 100644 --- a/packages/core/src/utils/schemaValidator.test.ts +++ b/packages/core/src/utils/schemaValidator.test.ts @@ -209,4 +209,89 @@ describe('SchemaValidator', () => { expect(params.is_background).toBe(true); }); }); + + describe('JSON Schema version support', () => { + it('should support JSON Schema draft-2020-12', () => { + const schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + url: { type: 'string' }, + }, + required: ['url'], + }; + const params = { url: 'https://example.com' }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + }); + + it('should validate correctly with draft-2020-12 schema', () => { + const schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + count: { type: 'integer' }, + }, + required: ['count'], + }; + const validParams = { count: 42 }; + const invalidParams = { count: 'not a number' }; + + expect(SchemaValidator.validate(schema, validParams)).toBeNull(); + expect(SchemaValidator.validate(schema, invalidParams)).not.toBeNull(); + }); + + it('should support JSON Schema draft-07 (default)', () => { + const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }; + const params = { name: 'test' }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + }); + + it('should handle nested schemas with $schema', () => { + const schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + config: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + }, + }, + }, + }; + const params = { config: { enabled: true } }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + }); + + it('should support 2020-12 specific keywords like prefixItems', () => { + const schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'array', + prefixItems: [{ type: 'string' }, { type: 'integer' }], + }; + const params = ['hello', 42]; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + }); + + it('should gracefully handle unsupported schema versions', () => { + // draft-2019-09 is not supported by Ajv by default + const schema = { + $schema: 'https://json-schema.org/draft/2019-09/schema', + type: 'object', + properties: { + value: { type: 'string' }, + }, + }; + const params = { value: 'test' }; + // Should skip validation and return null (graceful degradation) + expect(SchemaValidator.validate(schema, params)).toBeNull(); + }); + }); }); diff --git a/packages/core/src/utils/schemaValidator.ts b/packages/core/src/utils/schemaValidator.ts index 2dad48332..d480b03df 100644 --- a/packages/core/src/utils/schemaValidator.ts +++ b/packages/core/src/utils/schemaValidator.ts @@ -4,33 +4,68 @@ * SPDX-License-Identifier: Apache-2.0 */ -import AjvPkg 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 { createDebugLogger } 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 debugLogger = createDebugLogger('SchemaValidator'); + +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 { /** - * Returns null if the data confroms to the schema described by schema (or if schema + * Returns null if the data conforms to the schema described by schema (or if schema * is null). Otherwise, returns a string describing the error. */ static validate(schema: unknown | undefined, data: unknown): string | null { @@ -40,7 +75,30 @@ 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. + debugLogger.warn( + `Failed to compile schema (${ + + (schema as Record)?.['$schema'] ?? '' + }): ${error instanceof Error ? error.message : String(error)}. ` + + 'Skipping parameter validation.', + ); + return null; + } + let valid = validate(data); if (!valid && validate.errors) { // Coerce string boolean values ("true"/"false") to actual booleans @@ -48,7 +106,7 @@ export class SchemaValidator { valid = validate(data); if (!valid && validate.errors) { - return ajValidator.errorsText(validate.errors, { dataVar: 'params' }); + return validator.errorsText(validate.errors, { dataVar: 'params' }); } } return null;