From d6dc3127887825d348cea724f276e83caf9c43a7 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 7 Jul 2026 09:55:04 +0000 Subject: [PATCH 1/2] Construct the default AJV engine lazily on first validation AjvJsonSchemaValidator built its Ajv2020 instance (plus ajv-formats registration) in the constructor. Client and Server construct the default validator unconditionally, so every embedding application paid several milliseconds of AJV instantiation at startup even when no JSON Schema validation ever runs - a tax on CLI cold start in particular. Defer engine creation to the first getValidator() call via a private lazy getter. A caller-supplied engine is still used as-is from construction, the 2020-12 dialect check still fires before any engine is built, and the public API is unchanged. --- .../src/validators/ajvProvider.ts | 22 ++++--- .../test/validators/ajvLazyInit.test.ts | 57 +++++++++++++++++++ 2 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 packages/core-internal/test/validators/ajvLazyInit.test.ts diff --git a/packages/core-internal/src/validators/ajvProvider.ts b/packages/core-internal/src/validators/ajvProvider.ts index 0a24fe4533..89768093a1 100644 --- a/packages/core-internal/src/validators/ajvProvider.ts +++ b/packages/core-internal/src/validators/ajvProvider.ts @@ -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(schema: JsonSchemaType): JsonSchemaValidator { @@ -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 => { 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) }; }; } diff --git a/packages/core-internal/test/validators/ajvLazyInit.test.ts b/packages/core-internal/test/validators/ajvLazyInit.test.ts new file mode 100644 index 0000000000..1823b60fdc --- /dev/null +++ b/packages/core-internal/test/validators/ajvLazyInit.test.ts @@ -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(); + }); +}); From 262b9967f35cd161cda7b91b3d2576d316225fdd Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 10 Jul 2026 14:07:26 +0000 Subject: [PATCH 2/2] Add changeset for lazy Ajv engine construction No-Verification-Needed: changeset-only commit --- .changeset/lazy-ajv-engine.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/lazy-ajv-engine.md diff --git a/.changeset/lazy-ajv-engine.md b/.changeset/lazy-ajv-engine.md new file mode 100644 index 0000000000..12ce0852ba --- /dev/null +++ b/.changeset/lazy-ajv-engine.md @@ -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.