Skip to content

Commit 5a3897c

Browse files
committed
feat(core,server): default to Ajv2020 dialect + close SEP-2106 test gaps
- ajvProvider: use Ajv2020 so the default Node validator honors the 2020-12 dialect (prefixItems etc.); previously new Ajv() ran draft-07 and silently ignored 2020-12 keywords (R-2106-1/2). - add MCP_DEFAULT_SCHEMA_DIALECT='2020-12' as the single source of truth; cfWorker provider defaults through it. - refactor the server structuredContent text-fallback from in-place mutation to a pure withStructuredContentTextFallback() so the tools/call path is side-effect-free. - tests: Ajv2020 prefixItems regression (both validators); standardSchema io:'output' branch; spec.types<->schemas field-level mirror; registerTool compile-time Output typing; falsy structuredContent round-trip (false/""/null); schema-safety guards surfacing cleanly via fromJsonSchema. - changeset: note the Ajv2020 default-dialect fix.
1 parent ec6917b commit 5a3897c

11 files changed

Lines changed: 231 additions & 18 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ Implement SEP-2106: tool `inputSchema`/`outputSchema` conform to JSON Schema 202
1313
- `McpServer.registerTool` type-checks a handler's returned `structuredContent` against the tool's `outputSchema` inferred output.
1414
- Servers returning array or primitive `structuredContent` automatically also emit a serialized `TextContent` block, so pre-SEP clients can fall back to the text content.
1515
- Built-in validators refuse to dereference non-same-document `$ref`/`$dynamicRef` (SSRF guard) and reject schemas exceeding depth / subschema-count bounds (composition-DoS guard).
16+
- 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 (`MCP_DEFAULT_SCHEMA_DIALECT`).

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ export type { AjvJsonSchemaValidator } from './validators/ajvProvider.js';
2020
export type { CfWorkerJsonSchemaValidator, CfWorkerSchemaDraft } from './validators/cfWorkerProvider.js';
2121
export * from './validators/fromJsonSchema.js';
2222
export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './validators/types.js';
23+
export { MCP_DEFAULT_SCHEMA_DIALECT } from './validators/types.js';

packages/core/src/validators/ajvProvider.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
* AJV-based JSON Schema validator provider
33
*/
44

5-
import { Ajv } from 'ajv';
5+
import type { Ajv } from 'ajv';
6+
import { Ajv2020 } from 'ajv/dist/2020.js';
67
import _addFormats from 'ajv-formats';
78

89
import { assertSchemaSafeToCompile } from './schemaBounds.js';
@@ -23,7 +24,13 @@ interface AjvValidateFunction {
2324
}
2425

2526
function createDefaultAjvInstance(): Ajv {
26-
const ajv = new Ajv({
27+
// SEP-2106: MCP tool schemas default to the JSON Schema 2020-12 dialect when no `$schema` is
28+
// declared. Plain `Ajv` is draft-07 and *silently ignores* 2020-12 keywords such as
29+
// `prefixItems` (e.g. it would accept `[1, "a"]` for a `[string, number]` tuple), which would
30+
// make validation disagree with the declared schema. `Ajv2020` runs the 2020-12 meta-schema and
31+
// vocabulary, matching the cfworker default (`draft: '2020-12'`) used in the browser/workerd
32+
// builds.
33+
const ajv = new Ajv2020({
2734
strict: false,
2835
validateFormats: true,
2936
validateSchema: false,

packages/core/src/validators/cfWorkerProvider.ts

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

1313
import { assertSchemaSafeToCompile } from './schemaBounds.js';
1414
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types.js';
15+
import { MCP_DEFAULT_SCHEMA_DIALECT } from './types.js';
1516

1617
/**
1718
* JSON Schema draft version supported by `@cfworker/json-schema`.
@@ -48,7 +49,8 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator {
4849
*/
4950
constructor(options?: { shortcircuit?: boolean; draft?: CfWorkerSchemaDraft }) {
5051
this.shortcircuit = options?.shortcircuit ?? true;
51-
this.draft = options?.draft ?? '2020-12';
52+
// SEP-2106: default to the MCP-wide dialect (2020-12) when the caller does not pin one.
53+
this.draft = options?.draft ?? MCP_DEFAULT_SCHEMA_DIALECT;
5254
}
5355

5456
/**

packages/core/src/validators/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ import type { JSONSchema } from 'json-schema-typed';
1313
*/
1414
export type JsonSchemaType = JSONSchema.Interface;
1515

16+
/**
17+
* The JSON Schema dialect MCP tool `inputSchema`/`outputSchema` default to when no explicit
18+
* `$schema` is declared (SEP-2106).
19+
*
20+
* Both built-in validators are configured to this dialect — `AjvJsonSchemaValidator` via `Ajv2020`
21+
* and `CfWorkerJsonSchemaValidator` via its `draft: '2020-12'` default — so the answer to "what
22+
* dialect does MCP assume?" lives in exactly one place rather than being an implicit per-provider
23+
* default. Custom `jsonSchemaValidator` implementations SHOULD also default to this dialect.
24+
*/
25+
export const MCP_DEFAULT_SCHEMA_DIALECT = '2020-12' as const;
26+
1627
/**
1728
* Result of a JSON Schema validation operation
1829
*/

packages/core/test/types/specTypeSchema.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import type {
1212
JSONRPCRequest,
1313
JSONValue,
1414
ResourceTemplateType,
15-
Tool
15+
Tool,
16+
ToolResultContent
1617
} from '../../src/types/types.js';
1718

1819
describe('specTypeSchemas', () => {
@@ -148,6 +149,38 @@ describe('SpecTypeName / SpecTypes (type-level)', () => {
148149
});
149150
});
150151

152+
// SEP-2106 / R-2106-6/7/8: the hand-written interfaces in spec.types.ts and the runtime Zod schemas
153+
// in schemas.ts must describe the same shape. The whole-type assertions above already enforce this
154+
// for `Tool`/`CallToolResult`, but these field-level checks make the mirror an explicit, enforced
155+
// invariant: a future change that widens one file's `inputSchema`/`outputSchema`/`structuredContent`
156+
// without mirroring the other fails *here*, pointing straight at the offending field.
157+
describe('SEP-2106 spec.types ↔ schemas mirror (type-level)', () => {
158+
it('Tool.inputSchema keeps a required root type:"object" but is otherwise open', () => {
159+
expectTypeOf<Tool['inputSchema']['type']>().toEqualTypeOf<'object'>();
160+
// Open-ended: arbitrary 2020-12 keywords are accepted alongside `type`.
161+
expectTypeOf<Tool['inputSchema']['oneOf']>().toEqualTypeOf<unknown>();
162+
expectTypeOf<Tool['inputSchema']['$schema']>().toEqualTypeOf<string | undefined>();
163+
});
164+
165+
it('Tool.outputSchema drops the root type:"object" requirement', () => {
166+
// No required `type` member: indexing `type` resolves through the `[key: string]: unknown`
167+
// index signature, not a `'object'` literal.
168+
expectTypeOf<NonNullable<Tool['outputSchema']>['type']>().toEqualTypeOf<unknown>();
169+
expectTypeOf<NonNullable<Tool['outputSchema']>['$schema']>().toEqualTypeOf<string | undefined>();
170+
});
171+
172+
it('CallToolResult.structuredContent and ToolResultContent.structuredContent are any JSON value (unknown)', () => {
173+
expectTypeOf<CallToolResult['structuredContent']>().toEqualTypeOf<unknown>();
174+
expectTypeOf<ToolResultContent['structuredContent']>().toEqualTypeOf<unknown>();
175+
});
176+
177+
it('the inferred (schemas.ts) types equal the hand-written (spec.types.ts) types end to end', () => {
178+
expectTypeOf<SpecTypes['Tool']>().toEqualTypeOf<Tool>();
179+
expectTypeOf<SpecTypes['CallToolResult']>().toEqualTypeOf<CallToolResult>();
180+
expectTypeOf<SpecTypes['ToolResultContent']>().toEqualTypeOf<ToolResultContent>();
181+
});
182+
});
183+
151184
describe('SPEC_SCHEMA_KEYS allowlist', () => {
152185
// Mirrors the exclusion comment in specTypeSchema.ts. If this list grows, confirm the new
153186
// entry has no public type in types.ts before adding it here; otherwise add it to the allowlist.

packages/core/test/util/standardSchema.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,35 @@ describe('standardSchemaToJsonSchema', () => {
3939
expect(keys.filter(k => k === 'type')).toHaveLength(1);
4040
expect(result.type).toBe('object');
4141
});
42+
43+
// SEP-2106 / R-2106-7: a tool's `outputSchema` may be any valid JSON Schema 2020-12 — arrays,
44+
// primitives, or compositions — so the `io: 'output'` branch must return the converted schema
45+
// unchanged, never forcing (or rejecting based on) a root `type: 'object'`.
46+
describe("io: 'output' (SEP-2106 outputSchema)", () => {
47+
test('returns a non-object root unchanged (array)', () => {
48+
const result = standardSchemaToJsonSchema(z.array(z.number()), 'output');
49+
50+
expect(result.type).toBe('array');
51+
expect(result.items).toBeDefined();
52+
});
53+
54+
test('returns a primitive root unchanged (number)', () => {
55+
const result = standardSchemaToJsonSchema(z.number(), 'output');
56+
57+
expect(result.type).toBe('number');
58+
});
59+
60+
test('does not force type:object onto an object output schema', () => {
61+
const result = standardSchemaToJsonSchema(z.object({ x: z.string() }), 'output');
62+
63+
const keys = Object.keys(result);
64+
expect(keys.filter(k => k === 'type')).toHaveLength(1);
65+
expect(result.type).toBe('object');
66+
});
67+
68+
test('does not throw for a non-object type (unlike input)', () => {
69+
expect(() => standardSchemaToJsonSchema(z.string(), 'output')).not.toThrow();
70+
expect(() => standardSchemaToJsonSchema(z.array(z.string()), 'output')).not.toThrow();
71+
});
72+
});
4273
});

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,22 @@ describe('JSON Schema Validators', () => {
391391
expect(validator('specific-value').valid).toBe(true);
392392
expect(validator('other-value').valid).toBe(false);
393393
});
394+
395+
// SEP-2106 / R-2106-2: the default validators MUST run the 2020-12 dialect, not draft-07.
396+
// `prefixItems` is a 2020-12 keyword; draft-07 silently ignores it (accepting any tuple),
397+
// so this is the canonical guard that the default dialect is wired correctly. A plain
398+
// draft-07 `new Ajv()` would let `[1, 'a']` validate against a `[string, number]` tuple.
399+
it('honors prefixItems (2020-12 tuple) on the default dialect', () => {
400+
const schema: JsonSchemaType = {
401+
type: 'array',
402+
prefixItems: [{ type: 'string' }, { type: 'number' }]
403+
};
404+
const validator = provider.getValidator(schema);
405+
406+
expect(validator(['a', 1]).valid).toBe(true);
407+
// draft-07 would (incorrectly) accept this because it ignores prefixItems.
408+
expect(validator([1, 'a']).valid).toBe(false);
409+
});
394410
});
395411

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

packages/server/src/server/mcp.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -177,10 +177,7 @@ export class McpServer {
177177
// Per SEP-2106, a server returning array or primitive structuredContent MUST also emit a
178178
// TextContent block with the serialized JSON, so pre-SEP clients that only understand
179179
// object-typed structuredContent can fall back to the text content.
180-
if (isCallToolResult(result)) {
181-
ensureStructuredContentTextFallback(result);
182-
}
183-
return result;
180+
return isCallToolResult(result) ? withStructuredContentTextFallback(result) : result;
184181
} catch (error) {
185182
if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) {
186183
throw error; // Return the error to the caller without wrapping in CallToolResult
@@ -1169,27 +1166,35 @@ export type RegisteredTool = {
11691166
};
11701167

11711168
/**
1172-
* Ensures backward compatibility for non-object `structuredContent` (SEP-2106).
1169+
* Returns a {@link CallToolResult} with a backward-compatibility text block added when required by
1170+
* SEP-2106, without mutating the input.
11731171
*
11741172
* Servers that return array or primitive `structuredContent` MUST also include a {@link TextContent}
11751173
* block with the serialized JSON, so pre-SEP clients that only understand object-typed
1176-
* `structuredContent` can fall back to the text content. Object `structuredContent` (the only shape
1177-
* pre-SEP clients accept) needs no fallback, and a result that already carries a text block is left
1178-
* untouched — the handler is assumed to have provided its own representation.
1174+
* `structuredContent` can fall back to the text content. The original result is returned unchanged
1175+
* (same reference) when no fallback is needed:
1176+
*
1177+
* - no `structuredContent` present, or
1178+
* - `structuredContent` is a plain object (the only shape pre-SEP clients accept), or
1179+
* - the result already carries a text block — the handler is assumed to have provided its own
1180+
* representation.
1181+
*
1182+
* Otherwise a new result is returned with a serialized text block appended; the input is left
1183+
* untouched so the request handler stays a side-effect-free pipeline.
11791184
*/
1180-
function ensureStructuredContentTextFallback(result: CallToolResult): void {
1185+
function withStructuredContentTextFallback(result: CallToolResult): CallToolResult {
11811186
const structuredContent = result.structuredContent;
11821187
if (structuredContent === undefined) {
1183-
return;
1188+
return result;
11841189
}
11851190
const isPlainObject = structuredContent !== null && typeof structuredContent === 'object' && !Array.isArray(structuredContent);
11861191
if (isPlainObject) {
1187-
return;
1192+
return result;
11881193
}
11891194
if (result.content.some(block => block.type === 'text')) {
1190-
return;
1195+
return result;
11911196
}
1192-
result.content = [...result.content, { type: 'text', text: JSON.stringify(structuredContent) }];
1197+
return { ...result, content: [...result.content, { type: 'text', text: JSON.stringify(structuredContent) }] };
11931198
}
11941199

11951200
/**

packages/server/test/server/mcp.compat.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,61 @@ describe('InferRawShape', () => {
127127
expectTypeOf<S>().toEqualTypeOf<{ a: string; b?: string | undefined }>();
128128
});
129129
});
130+
131+
// SEP-2106 / R-2106-3: when a tool declares an `outputSchema`, `registerTool` infers it as the
132+
// `Output` type param so the handler's returned `structuredContent` is checked against the schema's
133+
// inferred output at compile time. These cases pin that contract: correct shapes compile, wrong
134+
// shapes fail to type-check (guarded by @ts-expect-error so a regression that loosens the typing
135+
// turns these into compile errors). Type-only — registration side effects are covered above.
136+
describe('registerTool compile-time outputSchema typing (SEP-2106)', () => {
137+
it('accepts structuredContent matching a Standard Schema outputSchema', () => {
138+
const server = new McpServer({ name: 't', version: '1.0.0' });
139+
140+
server.registerTool('bmi', { outputSchema: z.object({ bmi: z.number() }) }, async () => ({
141+
content: [{ type: 'text' as const, text: '22.9' }],
142+
structuredContent: { bmi: 22.9 }
143+
}));
144+
});
145+
146+
it('rejects structuredContent that does not match the outputSchema', () => {
147+
const server = new McpServer({ name: 't', version: '1.0.0' });
148+
149+
// The return-type mismatch surfaces at the registerTool call (the handler's return type is
150+
// contextually checked against ToolResultFor<Output>), so the directive sits on this line.
151+
// @ts-expect-error - bmi must be a number, not a string
152+
server.registerTool('bmi', { outputSchema: z.object({ bmi: z.number() }) }, async () => ({
153+
content: [{ type: 'text' as const, text: 'x' }],
154+
structuredContent: { bmi: 'not-a-number' }
155+
}));
156+
});
157+
158+
it('allows omitting structuredContent at compile time (the MUST-return rule is runtime-enforced)', () => {
159+
const server = new McpServer({ name: 't', version: '1.0.0' });
160+
161+
// CallToolResultWithStructuredContent<T> types structuredContent as optional (`?: T`), so a
162+
// handler that omits it still compiles. The "outputSchema implies structuredContent" rule is
163+
// enforced at runtime by validateToolOutput (covered in client/server runtime tests), not by
164+
// the type system — this documents and pins that boundary.
165+
server.registerTool('bmi', { outputSchema: z.object({ bmi: z.number() }) }, async () => ({
166+
content: [{ type: 'text' as const, text: 'x' }]
167+
}));
168+
});
169+
170+
it('supports a non-object (array) outputSchema per SEP-2106', () => {
171+
const server = new McpServer({ name: 't', version: '1.0.0' });
172+
173+
server.registerTool('forecast', { outputSchema: z.array(z.object({ temp: z.number() })) }, async () => ({
174+
content: [{ type: 'text' as const, text: '[]' }],
175+
structuredContent: [{ temp: 1 }]
176+
}));
177+
});
178+
179+
it('allows any JSON value in structuredContent when no outputSchema is declared', () => {
180+
const server = new McpServer({ name: 't', version: '1.0.0' });
181+
182+
server.registerTool('free', {}, async () => ({
183+
content: [{ type: 'text' as const, text: '42' }],
184+
structuredContent: 42
185+
}));
186+
});
187+
});

0 commit comments

Comments
 (0)