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. 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(); + }); +});