Skip to content

Commit 21d0b1b

Browse files
committed
fix: support MCP JSON Schema dialects
1 parent 39bf1a0 commit 21d0b1b

4 files changed

Lines changed: 466 additions & 18 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { describe, expect, test } from 'vitest';
2+
import {
3+
compileMcpJsonSchemaValidator,
4+
UnsupportedMcpJsonSchemaDialectError,
5+
UnsupportedMcpJsonSchemaFeatureError,
6+
} from './mcpJsonSchemaValidator';
7+
8+
describe('compileMcpJsonSchemaValidator', () => {
9+
test('uses draft-07 when it is explicitly declared', () => {
10+
const validate = compileMcpJsonSchemaValidator({
11+
$schema: 'http://json-schema.org/draft-07/schema#',
12+
type: 'array',
13+
items: [{ type: 'string' }],
14+
additionalItems: false,
15+
});
16+
17+
expect(validate(['valid'])).toBe(true);
18+
expect(validate(['valid', 'extra'])).toBe(false);
19+
});
20+
21+
test('uses draft 2019-09 for its declared meta-schema', () => {
22+
const validate = compileMcpJsonSchemaValidator({
23+
$schema: 'https://json-schema.org/draft/2019-09/schema',
24+
type: 'object',
25+
properties: {
26+
known: { type: 'string' },
27+
},
28+
unevaluatedProperties: false,
29+
});
30+
31+
expect(validate({ known: 'value' })).toBe(true);
32+
expect(validate({ known: 'value', extra: true })).toBe(false);
33+
expect(validate.errors).toEqual(expect.arrayContaining([
34+
expect.objectContaining({ keyword: 'unevaluatedProperties' }),
35+
]));
36+
});
37+
38+
test.each([
39+
['when explicitly declared', 'https://json-schema.org/draft/2020-12/schema'],
40+
['by default when omitted', undefined],
41+
])('uses draft 2020-12 %s', (_name, dialect) => {
42+
const validate = compileMcpJsonSchemaValidator({
43+
...(dialect ? { $schema: dialect } : {}),
44+
type: 'array',
45+
prefixItems: [{ type: 'string' }],
46+
items: false,
47+
});
48+
49+
expect(validate(['valid'])).toBe(true);
50+
expect(validate([42])).toBe(false);
51+
expect(validate(['valid', 'extra'])).toBe(false);
52+
});
53+
54+
test('preserves all-errors validation and permits unknown keywords', () => {
55+
const validate = compileMcpJsonSchemaValidator({
56+
type: 'object',
57+
required: ['first', 'second'],
58+
unknownServerKeyword: true,
59+
});
60+
61+
expect(validate({})).toBe(false);
62+
expect(validate.errors?.filter(error => error.keyword === 'required')).toHaveLength(2);
63+
});
64+
65+
test('does not conflict when separate server schemas reuse the same root ID', () => {
66+
const first = compileMcpJsonSchemaValidator({
67+
$id: 'https://schemas.example.com/tool-input',
68+
type: 'string',
69+
});
70+
const second = compileMcpJsonSchemaValidator({
71+
$id: 'https://schemas.example.com/tool-input',
72+
type: 'number',
73+
});
74+
75+
expect(first('value')).toBe(true);
76+
expect(second(42)).toBe(true);
77+
});
78+
79+
test.each([
80+
'http://json-schema.org/draft-07/schema#',
81+
'https://json-schema.org/draft/2019-09/schema',
82+
'https://json-schema.org/draft/2020-12/schema',
83+
])('does not let a remote $id remove the %s meta-schema', (dialect) => {
84+
const collidingSchema = compileMcpJsonSchemaValidator({
85+
$schema: dialect,
86+
$id: dialect,
87+
type: 'object',
88+
});
89+
90+
expect(collidingSchema({})).toBe(true);
91+
expect(() => compileMcpJsonSchemaValidator({
92+
$schema: dialect,
93+
type: 'object',
94+
})).not.toThrow();
95+
});
96+
97+
test('resolves document-local 2020-12 references after compilation', () => {
98+
const validate = compileMcpJsonSchemaValidator({
99+
$schema: 'https://json-schema.org/draft/2020-12/schema',
100+
$defs: {
101+
identifier: { type: 'string', minLength: 1 },
102+
},
103+
type: 'object',
104+
properties: {
105+
id: { $ref: '#/$defs/identifier' },
106+
},
107+
required: ['id'],
108+
});
109+
110+
expect(validate({ id: 'issue-id' })).toBe(true);
111+
expect(validate({ id: '' })).toBe(false);
112+
});
113+
114+
test('rejects Ajv asynchronous schemas instead of bypassing validation', () => {
115+
expect(() => compileMcpJsonSchemaValidator({
116+
$async: true,
117+
type: 'object',
118+
})).toThrow(UnsupportedMcpJsonSchemaFeatureError);
119+
});
120+
121+
test.each([
122+
'http://json-schema.org/draft-04/schema#',
123+
'https://malicious.example/schema?access_token=secret-token',
124+
])('rejects unsupported dialects without echoing the declaration', (declaredDialect) => {
125+
let thrown: unknown;
126+
try {
127+
compileMcpJsonSchemaValidator({
128+
$schema: declaredDialect,
129+
type: 'object',
130+
});
131+
} catch (error) {
132+
thrown = error;
133+
}
134+
135+
expect(thrown).toBeInstanceOf(UnsupportedMcpJsonSchemaDialectError);
136+
expect(thrown).toMatchObject({
137+
name: 'UnsupportedMcpJsonSchemaDialectError',
138+
reason: 'unsupported_json_schema_dialect',
139+
});
140+
expect((thrown as Error).message).not.toContain(declaredDialect);
141+
expect((thrown as Error).message).not.toContain('secret-token');
142+
});
143+
144+
test('rejects a non-string declared dialect safely', () => {
145+
expect(() => compileMcpJsonSchemaValidator({
146+
$schema: 202012,
147+
type: 'object',
148+
})).toThrow(UnsupportedMcpJsonSchemaDialectError);
149+
});
150+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv';
2+
import Ajv2019 from 'ajv/dist/2019.js';
3+
import Ajv2020 from 'ajv/dist/2020.js';
4+
5+
const AJV_OPTIONS = {
6+
addUsedSchema: false,
7+
allErrors: true,
8+
strict: false,
9+
} as const;
10+
11+
const JSON_SCHEMA_DIALECT = {
12+
DRAFT_07: 'draft-07',
13+
DRAFT_2019_09: '2019-09',
14+
DRAFT_2020_12: '2020-12',
15+
} as const;
16+
17+
type JsonSchemaDialect = typeof JSON_SCHEMA_DIALECT[keyof typeof JSON_SCHEMA_DIALECT];
18+
19+
const draft07Ajv = new Ajv(AJV_OPTIONS);
20+
const draft2019Ajv = new Ajv2019(AJV_OPTIONS);
21+
const draft2020Ajv = new Ajv2020(AJV_OPTIONS);
22+
23+
const DIALECT_BY_META_SCHEMA_URI = new Map<string, JsonSchemaDialect>([
24+
['http://json-schema.org/draft-07/schema', JSON_SCHEMA_DIALECT.DRAFT_07],
25+
['https://json-schema.org/draft/2019-09/schema', JSON_SCHEMA_DIALECT.DRAFT_2019_09],
26+
['https://json-schema.org/draft/2020-12/schema', JSON_SCHEMA_DIALECT.DRAFT_2020_12],
27+
]);
28+
29+
export class UnsupportedMcpJsonSchemaDialectError extends Error {
30+
readonly reason = 'unsupported_json_schema_dialect';
31+
32+
constructor() {
33+
super('MCP tool schema declares an unsupported JSON Schema dialect. Supported dialects are draft-07, 2019-09, and 2020-12.');
34+
this.name = 'UnsupportedMcpJsonSchemaDialectError';
35+
}
36+
}
37+
38+
export class UnsupportedMcpJsonSchemaFeatureError extends Error {
39+
readonly reason = 'unsupported_json_schema_feature';
40+
41+
constructor() {
42+
super('MCP tool schema uses an unsupported JSON Schema extension.');
43+
this.name = 'UnsupportedMcpJsonSchemaFeatureError';
44+
}
45+
}
46+
47+
/**
48+
* Compiles an MCP tool schema with the Ajv implementation for its declared
49+
* JSON Schema dialect. MCP 2025-11-25 defines 2020-12 as the default when a
50+
* schema does not explicitly declare another dialect.
51+
*/
52+
export function compileMcpJsonSchemaValidator(schema: unknown): ValidateFunction {
53+
rejectAsyncSchema(schema);
54+
const dialect = getJsonSchemaDialect(schema);
55+
56+
switch (dialect) {
57+
case JSON_SCHEMA_DIALECT.DRAFT_07:
58+
return compileWithAjv(draft07Ajv, schema);
59+
case JSON_SCHEMA_DIALECT.DRAFT_2019_09:
60+
return compileWithAjv(draft2019Ajv, schema);
61+
case JSON_SCHEMA_DIALECT.DRAFT_2020_12:
62+
return compileWithAjv(draft2020Ajv, schema);
63+
}
64+
}
65+
66+
export function formatMcpJsonSchemaValidationErrors(
67+
errors: ErrorObject[] | null | undefined,
68+
): string {
69+
// Ajv's error representation and formatter are shared across dialects.
70+
return draft2020Ajv.errorsText(errors);
71+
}
72+
73+
function compileWithAjv(
74+
ajv: Ajv | Ajv2019 | Ajv2020,
75+
schema: unknown,
76+
): ValidateFunction {
77+
// addUsedSchema: false prevents remote root $id values from entering Ajv's
78+
// shared schema registry or conflicting across MCP servers.
79+
return ajv.compile(schema as AnySchema);
80+
}
81+
82+
function rejectAsyncSchema(schema: unknown): void {
83+
if (
84+
typeof schema === 'object'
85+
&& schema !== null
86+
&& (schema as Record<string, unknown>).$async === true
87+
) {
88+
// $async is an Ajv extension rather than a JSON Schema keyword. The
89+
// surrounding AI SDK validation contract is synchronous, so accepting
90+
// it would treat a returned Promise as a successful validation.
91+
throw new UnsupportedMcpJsonSchemaFeatureError();
92+
}
93+
}
94+
95+
function getJsonSchemaDialect(schema: unknown): JsonSchemaDialect {
96+
if (typeof schema !== 'object' || schema === null || !Object.prototype.hasOwnProperty.call(schema, '$schema')) {
97+
return JSON_SCHEMA_DIALECT.DRAFT_2020_12;
98+
}
99+
100+
const declaredDialect = (schema as Record<string, unknown>).$schema;
101+
if (typeof declaredDialect !== 'string') {
102+
throw new UnsupportedMcpJsonSchemaDialectError();
103+
}
104+
105+
// An empty URI fragment does not change the selected dialect. Normalizing
106+
// it also accepts both forms used by draft-07 schemas in the wild.
107+
const normalizedDialect = declaredDialect.endsWith('#')
108+
? declaredDialect.slice(0, -1)
109+
: declaredDialect;
110+
const dialect = DIALECT_BY_META_SCHEMA_URI.get(normalizedDialect);
111+
if (!dialect) {
112+
throw new UnsupportedMcpJsonSchemaDialectError();
113+
}
114+
115+
return dialect;
116+
}

0 commit comments

Comments
 (0)