Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/quiet-v2-ajv-unknown-formats.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@modelcontextprotocol/core-internal': patch
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': patch
---

Suppress unknown-format warnings in the default Ajv validators for every supported schema dialect. Vendor-specific formats remain ignored, known formats remain validated, and other diagnostics and caller-supplied Ajv instances are unchanged.
17 changes: 15 additions & 2 deletions packages/core-internal/src/validators/ajvProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,18 @@ function createDefaultAjvInstance(engineClass: typeof Ajv2020 | typeof Ajv2019 |
strict: false,
validateFormats: true,
validateSchema: false,
allErrors: true
allErrors: true,
logger: {
log: (...args: unknown[]) => console.log(...args),
warn: (...args: unknown[]) => {
// Unknown formats are ignored under strict: false; avoid noise for each schema path.
if (typeof args[0] === 'string' && args[0].startsWith('unknown format')) {
return;
}
console.warn(...args);
},
error: (...args: unknown[]) => console.error(...args)
}
});
addFormats(ajv);
return ajv;
Expand Down Expand Up @@ -92,7 +103,9 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator {
* `strict: false`, `validateFormats: true`, `validateSchema: false`, `allErrors: true`, and
* `ajv-formats` registered — **lazily, on the first {@linkcode getValidator} call needing each**, 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
* validates a JSON Schema) does not pay the ajv + ajv-formats instantiation cost.
* Unknown-format warnings are suppressed by the default engines; all other diagnostics are
* forwarded to the console. Pass a custom engine to retain unknown-format warnings. The parameter
* is typed structurally so consumers who don't pass an instance need not have `ajv` installed.
*/
constructor(ajv?: AjvLike) {
Expand Down
93 changes: 93 additions & 0 deletions packages/core-internal/test/validators/ajvUnknownFormat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Ajv as Draft7Ajv } from 'ajv';
import { Ajv2019 } from 'ajv/dist/2019.js';
import { Ajv2020 } from 'ajv/dist/2020.js';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { AjvJsonSchemaValidator } from '../../src/validators/ajvProvider';
import type { JsonSchemaType } from '../../src/validators/types';

const vendorFormatSchema: JsonSchemaType = {
type: 'object',
properties: {
a: { type: 'string', format: 'google-duration' },
b: { type: 'string', format: 'vendor-specific-format' },
nested: { type: 'object', properties: { c: { type: 'string', format: 'google-duration' } } }
},
required: ['a']
};

const dialects = [
{ name: 'default', schema: undefined, Engine: Ajv2020 },
{ name: '2020-12', schema: 'https://json-schema.org/draft/2020-12/schema', Engine: Ajv2020 },
{ name: '2019-09', schema: 'https://json-schema.org/draft/2019-09/schema', Engine: Ajv2019 },
{ name: 'draft-07', schema: 'http://json-schema.org/draft-07/schema#', Engine: Draft7Ajv },
{ name: 'draft-06', schema: 'http://json-schema.org/draft-06/schema#', Engine: Draft7Ajv }
];

afterEach(() => vi.restoreAllMocks());

describe.each(dialects)('unknown formats ($name)', ({ schema: dialect, Engine }) => {
const schema: JsonSchemaType = { ...vendorFormatSchema, ...(dialect ? { $schema: dialect } : {}) };

it('suppresses every warning across distinct schema compilations while preserving validation', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const provider = new AjvJsonSchemaValidator();
for (let i = 0; i < 2; i++) {
const validate = provider.getValidator({ ...schema, $id: `urn:test:unknown-format:${i}` });
expect(validate({ a: 'not-a-duration', b: 'anything', nested: { c: 'anything' } }).valid).toBe(true);
expect(validate({ a: 42 }).valid).toBe(false);
expect(validate({}).valid).toBe(false);
}
expect(warn).not.toHaveBeenCalled();
});

it('still enforces known formats', () => {
const validate = new AjvJsonSchemaValidator().getValidator({
...(dialect ? { $schema: dialect } : {}),
type: 'string',
format: 'email'
});
expect(validate('user@example.com').valid).toBe(true);
expect(validate('not-an-email').valid).toBe(false);
});

it('preserves caller-supplied diagnostics, including two warnings per subschema path', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const engine = new Engine({ strict: false, validateFormats: true, validateSchema: false, allErrors: true });
const logger = engine.logger;
new AjvJsonSchemaValidator(engine).getValidator(schema);
expect(engine.logger).toBe(logger);
// Three paths each produce two warnings with the pinned Ajv version.
expect(warn).toHaveBeenCalledTimes(6);
for (const [format, path] of [
['google-duration', '#/properties/a'],
['vendor-specific-format', '#/properties/b'],
['google-duration', '#/properties/nested/properties/c']
]) {
const message = `unknown format "${format}" ignored in schema at path "${path}"`;
expect(warn.mock.calls.filter(([value]) => value === message)).toHaveLength(2);
}
});
});

it('forwards other logger messages and their arguments unchanged', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const detail = { schema: 'example' };
const compile = Ajv2020.prototype.compile;
vi.spyOn(Ajv2020.prototype, 'compile').mockImplementation(function (this: Ajv2020, schema) {
this.logger.warn('another warning', detail);
this.logger.warn(detail, 'extra');
this.logger.log('unknown format diagnostic', detail);
this.logger.error('unknown format diagnostic', detail);
return compile.call(this, schema);
});
new AjvJsonSchemaValidator().getValidator({ type: 'string' });
expect(warn.mock.calls).toEqual([
['another warning', detail],
[detail, 'extra']
]);
expect(log).toHaveBeenCalledExactlyOnceWith('unknown format diagnostic', detail);
expect(error).toHaveBeenCalledExactlyOnceWith('unknown format diagnostic', detail);
});
Loading