mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-13 15:40:57 -07:00
feat(core): add draft-2020-12 JSON Schema support with lenient fallback (#15060)
Co-authored-by: A.K.M. Adib <adibakm@google.com> Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>)?.['$schema'] ?? '<no $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<string, unknown>)?.['$schema'] ?? '<no $schema>'
|
||||
}): ${error instanceof Error ? error.message : String(error)}. ` +
|
||||
'Skipping schema validation.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user