Skip to content

Commit 64848bd

Browse files
committed
fix(core): tolerate legacy tuple schemas
1 parent 527cfb3 commit 64848bd

6 files changed

Lines changed: 130 additions & 4 deletions

File tree

.changeset/sep-2106-json-schema-2020-12.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,7 @@ Implement SEP-2106: tool `inputSchema`/`outputSchema` conform to JSON Schema 202
1717
descriptive error instead of silently skipping output validation.
1818
- The default Node validator now uses `Ajv2020`, so the 2020-12 dialect is honored by default (previously `new Ajv()` ran draft-07 semantics and silently ignored keywords such as `prefixItems`). Both built-in validators now default to the `2020-12` dialect
1919
(`MCP_DEFAULT_SCHEMA_DIALECT`).
20+
- For compatibility with existing draft-07 tuple schemas, built-in validators using the default 2020-12 dialect normalize legacy `items: [...]` plus `additionalItems` syntax to the equivalent 2020-12 `prefixItems`/`items` form before compiling. New schemas should prefer
21+
`prefixItems` directly.
2022
- New opt-in `resolveExternalSchemaRefs(schema, options)` helper (the SEP's optional external-`$ref` mode): fetches and inlines non-local `$ref`s ahead of time into a self-contained schema. Disabled by default, enforces a host allowlist (and rejects loopback/link-local/private
2123
targets otherwise), `https`-only by default, with fetch timeout / response-size / document-count limits, dereference logging, and fail-closed on unresolved references.

docs/migration.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -983,6 +983,9 @@ const server = new McpServer(
983983
If you import from one of these subpaths in your own code, the corresponding peer dep (`ajv` + `ajv-formats`, or `@cfworker/json-schema`) needs to be installed in your `package.json`. The runtime shim continues to vendor a copy for the default code path, so you can use the
984984
subpath in some files and rely on the default in others. For AJV customization, use the re-exported `Ajv2020` class; a plain `Ajv` instance uses draft-07 semantics and will not validate JSON Schema 2020-12 keywords such as `prefixItems` the same way as MCP's default validator.
985985

986+
For compatibility with existing draft-07 tuple schemas, the built-in validators using the default 2020-12 dialect normalize legacy `items: [...]` plus `additionalItems` syntax to the equivalent 2020-12 `prefixItems`/`items` form before compiling. New schemas should use
987+
`prefixItems` directly.
988+
986989
To replace validation wholesale rather than customizing the built-in classes, implement the `jsonSchemaValidator` interface and pass your own implementation through the option above.
987990

988991
### Tool schemas conform to JSON Schema 2020-12; `structuredContent` may be any JSON value (SEP-2106)

packages/core/src/validators/ajvProvider.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Ajv2020 } from 'ajv/dist/2020.js';
77
import _addFormats from 'ajv-formats';
88

99
import { assertSchemaSafeToCompile } from './schemaBounds';
10+
import { normalizeLegacyTupleSchema } from './schemaCompatibility';
1011
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types';
1112

1213
/** Structural subset of the AJV interface used by {@link AjvJsonSchemaValidator}. */
@@ -67,6 +68,7 @@ function createDefaultAjvInstance(): Ajv {
6768
*/
6869
export class AjvJsonSchemaValidator implements jsonSchemaValidator {
6970
private _ajv: AjvLike;
71+
private _normalizeLegacyTuples: boolean;
7072

7173
/**
7274
* @param ajv - Optional pre-configured AJV-compatible instance. If omitted, a default instance is
@@ -76,16 +78,18 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator {
7678
*/
7779
constructor(ajv?: AjvLike) {
7880
this._ajv = ajv ?? createDefaultAjvInstance();
81+
this._normalizeLegacyTuples = ajv === undefined || ajv instanceof Ajv2020;
7982
}
8083

8184
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
8285
// SEP-2106: reject non-local $refs (SSRF) and over-budget schemas (composition DoS) before compiling.
8386
assertSchemaSafeToCompile(schema);
87+
const normalizedSchema = this._normalizeLegacyTuples ? normalizeLegacyTupleSchema(schema) : schema;
8488

8589
const ajvValidator =
86-
'$id' in schema && typeof schema.$id === 'string'
87-
? (this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema))
88-
: this._ajv.compile(schema);
90+
'$id' in normalizedSchema && typeof normalizedSchema.$id === 'string'
91+
? (this._ajv.getSchema(normalizedSchema.$id) ?? this._ajv.compile(normalizedSchema))
92+
: this._ajv.compile(normalizedSchema);
8993

9094
return (input: unknown): JsonSchemaValidatorResult<T> => {
9195
const valid = ajvValidator(input);

packages/core/src/validators/cfWorkerProvider.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import { Validator } from '@cfworker/json-schema';
1212

1313
import { assertSchemaSafeToCompile } from './schemaBounds';
14+
import { normalizeLegacyTupleSchema } from './schemaCompatibility';
1415
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types';
1516
import { MCP_DEFAULT_SCHEMA_DIALECT } from './types';
1617

@@ -64,9 +65,10 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator {
6465
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
6566
// SEP-2106: reject non-local $refs (SSRF) and over-budget schemas (composition DoS) before compiling.
6667
assertSchemaSafeToCompile(schema);
68+
const normalizedSchema = this.draft === MCP_DEFAULT_SCHEMA_DIALECT ? normalizeLegacyTupleSchema(schema) : schema;
6769

6870
// Cast to the cfworker Schema type - our JsonSchemaType is structurally compatible
69-
const validator = new Validator(schema as ConstructorParameters<typeof Validator>[0], this.draft, this.shortcircuit);
71+
const validator = new Validator(normalizedSchema as ConstructorParameters<typeof Validator>[0], this.draft, this.shortcircuit);
7072

7173
return (input: unknown): JsonSchemaValidatorResult<T> => {
7274
const result = validator.validate(input);
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { JsonSchemaType } from './types';
2+
3+
const DATA_VALUE_KEYWORDS = new Set(['const', 'default', 'enum', 'examples']);
4+
const SCHEMA_MAP_KEYWORDS = new Set(['$defs', 'definitions', 'dependencies', 'dependentSchemas', 'patternProperties', 'properties']);
5+
const SCHEMA_ARRAY_KEYWORDS = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems']);
6+
const SCHEMA_VALUE_KEYWORDS = new Set([
7+
'additionalProperties',
8+
'contains',
9+
'else',
10+
'if',
11+
'items',
12+
'not',
13+
'propertyNames',
14+
'then',
15+
'unevaluatedItems',
16+
'unevaluatedProperties'
17+
]);
18+
19+
function isJsonObject(value: unknown): value is Record<string, unknown> {
20+
return value !== null && typeof value === 'object' && !Array.isArray(value);
21+
}
22+
23+
function normalizeSchemaObject(schema: Record<string, unknown>): Record<string, unknown> {
24+
const tupleItems = Array.isArray(schema.items) ? schema.items : undefined;
25+
const normalized: Record<string, unknown> = {};
26+
27+
for (const [key, value] of Object.entries(schema)) {
28+
if ((key === 'items' || key === 'additionalItems') && tupleItems !== undefined) {
29+
continue;
30+
}
31+
32+
if (DATA_VALUE_KEYWORDS.has(key)) {
33+
normalized[key] = value;
34+
} else if (SCHEMA_MAP_KEYWORDS.has(key) && isJsonObject(value)) {
35+
normalized[key] = Object.fromEntries(
36+
Object.entries(value).map(([childKey, childValue]) => [childKey, normalizeSchema(childValue)])
37+
);
38+
} else if (SCHEMA_ARRAY_KEYWORDS.has(key) && Array.isArray(value)) {
39+
normalized[key] = value.map(child => normalizeSchema(child));
40+
} else if (SCHEMA_VALUE_KEYWORDS.has(key)) {
41+
normalized[key] = normalizeSchema(value);
42+
} else {
43+
normalized[key] = value;
44+
}
45+
}
46+
47+
if (tupleItems !== undefined) {
48+
if (!('prefixItems' in normalized)) {
49+
normalized.prefixItems = tupleItems.map(item => normalizeSchema(item));
50+
}
51+
52+
if ('additionalItems' in schema && schema.additionalItems !== true) {
53+
normalized.items = normalizeSchema(schema.additionalItems);
54+
}
55+
}
56+
57+
return normalized;
58+
}
59+
60+
function normalizeSchema(schema: unknown): unknown {
61+
if (schema === true || schema === false) {
62+
return schema;
63+
}
64+
65+
if (Array.isArray(schema)) {
66+
return schema.map(item => normalizeSchema(item));
67+
}
68+
69+
if (!isJsonObject(schema)) {
70+
return schema;
71+
}
72+
73+
return normalizeSchemaObject(schema);
74+
}
75+
76+
/**
77+
* JSON Schema 2020-12 replaced draft-07 tuple syntax (`items: [...]` plus
78+
* `additionalItems`) with `prefixItems` plus `items`. Normalize the legacy
79+
* tuple form before handing schemas to 2020-12 validators so older advertised
80+
* tool schemas remain callable.
81+
*/
82+
export function normalizeLegacyTupleSchema(schema: JsonSchemaType): JsonSchemaType {
83+
return normalizeSchema(schema) as JsonSchemaType;
84+
}

packages/core/test/validators/validators.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,37 @@ describe('JSON Schema Validators', () => {
407407
// draft-07 would (incorrectly) accept this because it ignores prefixItems.
408408
expect(validator([1, 'a']).valid).toBe(false);
409409
});
410+
411+
it('normalizes draft-07 tuple items arrays on the default dialect', () => {
412+
const schema = {
413+
type: 'object',
414+
properties: {
415+
value: {
416+
type: 'array',
417+
items: [{ type: 'string' }, { type: 'number' }]
418+
}
419+
},
420+
required: ['value']
421+
} as unknown as JsonSchemaType;
422+
const validator = provider.getValidator(schema);
423+
424+
expect(validator({ value: ['a', 1] }).valid).toBe(true);
425+
expect(validator({ value: [1, 'a'] }).valid).toBe(false);
426+
expect(validator({ value: ['a', 1, true] }).valid).toBe(true);
427+
});
428+
429+
it('normalizes draft-07 additionalItems false to a closed tuple', () => {
430+
const schema = {
431+
type: 'array',
432+
items: [{ type: 'string' }, { type: 'number' }],
433+
additionalItems: false
434+
} as unknown as JsonSchemaType;
435+
const validator = provider.getValidator(schema);
436+
437+
expect(validator(['a', 1]).valid).toBe(true);
438+
expect(validator([1, 'a']).valid).toBe(false);
439+
expect(validator(['a', 1, true]).valid).toBe(false);
440+
});
410441
});
411442

412443
describe('Complex real-world schemas', () => {

0 commit comments

Comments
 (0)