perf: construct the default Ajv engine lazily on first validation (#2458)

This commit is contained in:
Felix Weinberger 2026-07-13 17:09:06 +01:00 • committed by GitHub
commit 7c49b47fb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 78 additions and 7 deletions

View file

@ -0,0 +1,6 @@
---
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': patch
---
Construct the default Ajv validation engine lazily on first validation. Creating a `Client` or `Server` no longer pays the ajv + ajv-formats instantiation cost at startup when no JSON Schema validation ever runs.

View file

@ -79,7 +79,7 @@ function createDefaultAjvInstance(): AjvLike {
* ```
*/
export class AjvJsonSchemaValidator implements jsonSchemaValidator {
private readonly _ajv: AjvLike;
private _ajv: AjvLike | undefined;
/** True iff the constructor received a caller-supplied engine; the `$schema` check is skipped. */
private readonly _userAjv: boolean;
@ -88,12 +88,19 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator {
* used for **every** schema regardless of its declared `$schema` (the caller owns dialect
* choice). When omitted, the provider constructs a single `Ajv2020` instance with
* `strict: false`, `validateFormats: true`, `validateSchema: false`, `allErrors: true`, and
* `ajv-formats` registered. The parameter is typed structurally so consumers who don't pass an
* instance need not have `ajv` installed.
* `ajv-formats` registered — **lazily, on the first {@linkcode getValidator} call**, so
* constructing the provider (e.g. as the default validator of a `Client`/`Server` that never
* validates a JSON Schema) does not pay the ajv + ajv-formats instantiation cost. The parameter
* is typed structurally so consumers who don't pass an instance need not have `ajv` installed.
*/
constructor(ajv?: AjvLike) {
this._userAjv = ajv !== undefined;
this._ajv = ajv ?? createDefaultAjvInstance();
this._ajv = ajv;
}
/** The underlying engine — the default instance is created on first use. */
private get ajv(): AjvLike {
return (this._ajv ??= createDefaultAjvInstance());
}
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
@ -113,10 +120,11 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator {
);
}
const engine = this.ajv;
const ajvValidator =
'$id' in schema && typeof schema.$id === 'string'
? (this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema))
: this._ajv.compile(schema);
? (engine.getSchema(schema.$id) ?? engine.compile(schema))
: engine.compile(schema);
return (input: unknown): JsonSchemaValidatorResult<T> => {
const valid = ajvValidator(input);
@ -130,7 +138,7 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator {
: {
valid: false,
data: undefined,
errorMessage: this._ajv.errorsText(ajvValidator.errors)
errorMessage: engine.errorsText(ajvValidator.errors)
};
};
}

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { AjvJsonSchemaValidator } from '../../src/validators/ajvProvider';
/** Peeks at the provider's private engine slot without triggering the lazy getter. */
function engineSlot(provider: AjvJsonSchemaValidator): unknown {
return (provider as unknown as { _ajv: unknown })._ajv;
}
describe('AjvJsonSchemaValidator lazy engine construction', () => {
it('does not construct the default Ajv engine until the first getValidator call', () => {
const provider = new AjvJsonSchemaValidator();
expect(engineSlot(provider)).toBeUndefined();
const validate = provider.getValidator<{ a?: string }>({ type: 'object', properties: { a: { type: 'string' } } });
expect(engineSlot(provider)).toBeDefined();
expect(validate({ a: 'x' }).valid).toBe(true);
expect(validate({ a: 1 }).valid).toBe(false);
});
it('reuses one engine across getValidator calls', () => {
const provider = new AjvJsonSchemaValidator();
provider.getValidator({ type: 'string' });
const first = engineSlot(provider);
provider.getValidator({ type: 'number' });
expect(engineSlot(provider)).toBe(first);
});
it('uses a caller-supplied engine immediately and never replaces it', () => {
let compiles = 0;
const fake = {
compile: () => {
compiles += 1;
return Object.assign(() => true, { errors: undefined });
},
getSchema: () => undefined,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
errorsText: (_errors?: any) => ''
};
const provider = new AjvJsonSchemaValidator(fake);
expect(engineSlot(provider)).toBe(fake);
const validate = provider.getValidator({ type: 'object' });
expect(compiles).toBe(1);
expect(validate({}).valid).toBe(true);
expect(engineSlot(provider)).toBe(fake);
});
it('still rejects non-2020-12 $schema dialects before constructing the default engine', () => {
const provider = new AjvJsonSchemaValidator();
expect(() => provider.getValidator({ $schema: 'http://json-schema.org/draft-07/schema#', type: 'object' })).toThrow(
/unsupported dialect/
);
// The dialect check fires before engine construction — no engine was built for the rejected schema.
expect(engineSlot(provider)).toBeUndefined();
});
});