Skip to content

Commit d26e218

Browse files
fix(core,server): #2337 review-4 — Tool outputSchema type widen, module-level warn-once, recursive object-shape check, Ajv2020 customization docs
1 parent 3e6b90d commit d26e218

11 files changed

Lines changed: 134 additions & 42 deletions

File tree

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
/**
2-
* Customisation entry point for the AJV validator. Re-exports `Ajv` + `addFormats` from the
3-
* SDK's bundled copy, so customising the validator needs no extra installs.
2+
* Customisation entry point for the AJV validator. Re-exports `Ajv2020`, `Ajv`, and `addFormats`
3+
* from the SDK's bundled copy, so customising the validator needs no extra installs. Use `Ajv2020`
4+
* for a custom instance — `Ajv` is the draft-07 class (kept for opting back to the pre-SEP-1613
5+
* default) and would silently downgrade dialect.
46
*
57
* @example
68
* ```ts
7-
* import { Ajv, addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/client/validators/ajv';
9+
* import { Ajv2020, addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/client/validators/ajv';
810
*
9-
* const ajv = new Ajv({ strict: true, allErrors: true });
11+
* const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true });
1012
* addFormats(ajv);
1113
* const validator = new AjvJsonSchemaValidator(ajv);
1214
* ```
1315
*/
14-
export { addFormats, Ajv, AjvJsonSchemaValidator } from '@modelcontextprotocol/core/validators/ajv';
16+
export { addFormats, Ajv, Ajv2020, AjvJsonSchemaValidator } from '@modelcontextprotocol/core/validators/ajv';

packages/core/src/types/specTypeSchema.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -238,14 +238,21 @@ type GuardRecord = { readonly [K in SpecTypeName]: (value: unknown) => value is
238238

239239
/**
240240
* SEP-2106: the public `CallToolResult` / `CompatibilityCallToolResult` types widen
241-
* `structuredContent` to `unknown`; the era-neutral runtime schema in `schemas.ts` keeps the 2025
242-
* record shape for wire-parse byte-identity (Q10-L2). Override the registered runtime validators
243-
* here so `specTypeSchemas` / `isSpecType` accept the public shape without touching `schemas.ts`.
241+
* `structuredContent` to `unknown`, and the public `Tool` / `ListToolsResult` types widen
242+
* `outputSchema` to a loose JSON Schema document; the era-neutral runtime schema in `schemas.ts`
243+
* keeps the 2025 record / `type:'object'` shapes for wire-parse byte-identity (Q10-L2). Override the
244+
* registered runtime validators here so `specTypeSchemas` / `isSpecType` accept the public shape
245+
* without touching `schemas.ts`.
244246
*/
245247
const PublicCallToolResultSchema = schemas.CallToolResultSchema.extend({ structuredContent: z.unknown().optional() });
248+
const PublicToolSchema = schemas.ToolSchema.extend({
249+
outputSchema: z.object({ $schema: z.string().optional() }).catchall(z.unknown()).optional()
250+
});
246251
const SCHEMA_OVERRIDES: Partial<Record<ProtocolSchemaKey, z.ZodType>> = {
247252
CallToolResultSchema: PublicCallToolResultSchema,
248-
CompatibilityCallToolResultSchema: PublicCallToolResultSchema.or(schemas.ResultSchema.extend({ toolResult: z.unknown() }))
253+
CompatibilityCallToolResultSchema: PublicCallToolResultSchema.or(schemas.ResultSchema.extend({ toolResult: z.unknown() })),
254+
ToolSchema: PublicToolSchema,
255+
ListToolsResultSchema: schemas.ListToolsResultSchema.extend({ tools: z.array(PublicToolSchema) })
249256
};
250257

251258
const _specTypeSchemas: Record<string, StandardSchemaV1> = {};

packages/core/src/types/types.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,20 @@ type StripWireOnly<T> = T extends unknown ? { [K in keyof T as K extends WireOnl
220220
*/
221221
type WidenStructuredContent<T> = T extends unknown ? { [K in keyof T]: K extends 'structuredContent' ? unknown : T[K] } : never;
222222

223+
/**
224+
* SEP-2106: re-types `outputSchema` on the schema-inferred `Tool` to the loose JSON Schema 2020-12
225+
* shape (any JSON Schema document, not just `type:'object'`). The era-neutral runtime schema in
226+
* `schemas.ts` keeps the 2025 `type:'object'` constraint for wire-parse byte-identity (Q10-L2); only
227+
* the public TypeScript type widens. `Omit<T,'outputSchema'> & {…}` cannot be used here because
228+
* `Omit` over an index-signatured type erases the known keys.
229+
*/
230+
type Sep2106OutputSchema = { $schema?: string; [k: string]: unknown };
231+
type WidenToolOutputSchema<T> = T extends unknown
232+
? { [K in keyof T]: K extends 'outputSchema' ? Sep2106OutputSchema | undefined : T[K] }
233+
: never;
234+
/** Re-maps a schema-inferred type's `tools` member to the public (widened) {@linkcode Tool}`[]`. */
235+
type WithPublicTools<T> = T extends unknown ? { [K in keyof T]: K extends 'tools' ? Tool[] : T[K] } : never;
236+
223237
/* JSON-RPC types */
224238
export type ProgressToken = Infer<typeof ProgressTokenSchema>;
225239
export type Cursor = Infer<typeof CursorSchema>;
@@ -404,9 +418,14 @@ export type PromptListChangedNotification = Infer<typeof PromptListChangedNotifi
404418
/* Tools */
405419
export type ToolAnnotations = Infer<typeof ToolAnnotationsSchema>;
406420
export type ToolExecution = Infer<typeof ToolExecutionSchema>;
407-
export type Tool = Infer<typeof ToolSchema>;
421+
/**
422+
* `outputSchema` is widened to the loose SEP-2106 shape (any JSON Schema 2020-12 document, not just
423+
* `type:'object'`). The 2025-era wire parse retains the `type:'object'` runtime constraint; only the
424+
* public TypeScript type widens. {@linkcode ListToolsResult}`.tools` carries the same widened shape.
425+
*/
426+
export type Tool = WidenToolOutputSchema<Infer<typeof ToolSchema>>;
408427
export type ListToolsRequest = Infer<typeof ListToolsRequestSchema>;
409-
export type ListToolsResult = StripWireOnly<Infer<typeof ListToolsResultSchema>>;
428+
export type ListToolsResult = WithPublicTools<StripWireOnly<Infer<typeof ListToolsResultSchema>>>;
410429
export type CallToolRequestParams = Infer<typeof CallToolRequestParamsSchema>;
411430
/**
412431
* `structuredContent` is widened to `unknown` per SEP-2106 (the 2026-07-28 protocol revision allows
@@ -925,13 +944,16 @@ export type CreateMessageRequestParamsBase = Omit<CreateMessageRequestParams, 't
925944

926945
/**
927946
* {@linkcode CreateMessageRequestParams} with required tools - for tool-enabled overload.
947+
* `tools` keeps the schema-derived (2025-era, `type:'object'` outputSchema) shape: this overload is
948+
* the deprecated 2025-only push-sampling path, and the 2025 wire cannot carry a non-object
949+
* outputSchema regardless.
928950
*
929951
* @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains
930952
* in the specification for at least twelve months. Migrate to calling LLM
931953
* provider APIs directly.
932954
*/
933955
export interface CreateMessageRequestParamsWithTools extends CreateMessageRequestParams {
934-
tools: Tool[];
956+
tools: NonNullable<CreateMessageRequestParams['tools']>;
935957
}
936958

937959
export type CompleteRequestResourceTemplate = ExpandRecursively<

packages/core/src/util/standardSchema.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,8 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in
230230
/**
231231
* A typeless JSON Schema root is "provably object-shaped" when either it carries object keywords
232232
* directly (`properties`/`patternProperties`/`additionalProperties`/`required`), or it is a
233-
* composition (`oneOf`/`anyOf`/`allOf`) whose every member is itself `type:'object'`. Used to
233+
* composition (`oneOf`/`anyOf`/`allOf`) whose every member is itself `type:'object'` or recursively
234+
* provably object-shaped (e.g. a nested `discriminatedUnion`). `$ref` is not followed. Used to
234235
* decide whether stamping `type:'object'` is safe (redundant-but-valid) versus self-contradictory.
235236
*/
236237
function isProvablyObjectShapedRoot(schema: Record<string, unknown>): boolean {
@@ -240,7 +241,12 @@ function isProvablyObjectShapedRoot(schema: Record<string, unknown>): boolean {
240241
for (const key of ['oneOf', 'anyOf', 'allOf'] as const) {
241242
const members = schema[key];
242243
if (Array.isArray(members) && members.length > 0) {
243-
return members.every(m => m !== null && typeof m === 'object' && (m as Record<string, unknown>).type === 'object');
244+
return members.every(
245+
m =>
246+
m !== null &&
247+
typeof m === 'object' &&
248+
((m as Record<string, unknown>).type === 'object' || isProvablyObjectShapedRoot(m as Record<string, unknown>))
249+
);
244250
}
245251
}
246252
return false;

packages/core/src/validators/ajvProvider.examples.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @module
88
*/
99

10-
import { addFormats, Ajv, AjvJsonSchemaValidator } from './ajvProvider.js';
10+
import { addFormats, Ajv2020, AjvJsonSchemaValidator } from './ajvProvider.js';
1111

1212
/**
1313
* Example: Default AJV instance.
@@ -21,10 +21,13 @@ function AjvJsonSchemaValidator_default() {
2121

2222
/**
2323
* Example: Custom AJV instance.
24+
*
25+
* Use the re-exported `Ajv2020` class so the custom instance keeps validating JSON Schema 2020-12
26+
* (SEP-1613). Passing `new Ajv(...)` (the draft-07 class) would silently downgrade dialect.
2427
*/
2528
function AjvJsonSchemaValidator_customInstance() {
2629
//#region AjvJsonSchemaValidator_customInstance
27-
const ajv = new Ajv({ strict: true, allErrors: true });
30+
const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true });
2831
const validator = new AjvJsonSchemaValidator(ajv);
2932
//#endregion AjvJsonSchemaValidator_customInstance
3033
return validator;
@@ -33,12 +36,12 @@ function AjvJsonSchemaValidator_customInstance() {
3336
/**
3437
* Example: Custom AJV instance with formats registered.
3538
*
36-
* `Ajv` and `addFormats` are re-exported from this module so customising the validator
39+
* `Ajv2020` and `addFormats` are re-exported from this module so customising the validator
3740
* requires no extra `package.json` dependencies — both come from the SDK's bundled copy.
3841
*/
3942
function AjvJsonSchemaValidator_withFormats() {
4043
//#region AjvJsonSchemaValidator_withFormats
41-
const ajv = new Ajv({ strict: true, allErrors: true });
44+
const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true });
4245
addFormats(ajv);
4346
const validator = new AjvJsonSchemaValidator(ajv);
4447
//#endregion AjvJsonSchemaValidator_withFormats

packages/core/src/validators/ajvProvider.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,14 @@ function createDefaultAjvInstance(): AjvLike {
4747

4848
/**
4949
* AJV-backed JSON Schema validator. See `@modelcontextprotocol/{client,server}/validators/ajv`
50-
* for the customisation entry point (re-exports `Ajv` and `addFormats` from the bundled copy).
50+
* for the customisation entry point (re-exports `Ajv2020`, `Ajv`, and `addFormats` from the
51+
* bundled copy).
5152
*
5253
* Default validates as **JSON Schema 2020-12** (SEP-1613). Schemas declaring a different
5354
* `$schema` are rejected with `SchemaCompileError{kind:'unsupported-dialect'}`; pass a
54-
* pre-configured Ajv instance to validate other dialects.
55+
* pre-configured Ajv instance to validate other dialects. When passing a custom instance, use the
56+
* re-exported `Ajv2020` class — `new Ajv(...)` is the draft-07 class and would silently downgrade
57+
* dialect.
5558
*
5659
* @example Use with default configuration
5760
* ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_default"
@@ -60,13 +63,13 @@ function createDefaultAjvInstance(): AjvLike {
6063
*
6164
* @example Use with a custom AJV instance
6265
* ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_customInstance"
63-
* const ajv = new Ajv({ strict: true, allErrors: true });
66+
* const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true });
6467
* const validator = new AjvJsonSchemaValidator(ajv);
6568
* ```
6669
*
6770
* @example Register ajv-formats
6871
* ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_withFormats"
69-
* const ajv = new Ajv({ strict: true, allErrors: true });
72+
* const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true });
7073
* addFormats(ajv);
7174
* const validator = new AjvJsonSchemaValidator(ajv);
7275
* ```
@@ -149,5 +152,10 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator {
149152
* (`new AjvJsonSchemaValidator(new Ajv({ strict: false }))`).
150153
*/
151154
export { Ajv } from 'ajv';
155+
/**
156+
* 2020-12 AJV class — the dialect the default provider uses (SEP-1613). Re-exported so a custom
157+
* instance can be passed without an extra `ajv` install and without silently downgrading to draft-07.
158+
*/
159+
export { Ajv2020 } from 'ajv/dist/2020.js';
152160
/** `ajv-formats` default export, normalised through the CJS/ESM interop wrapper. */
153161
export { addFormats };

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,19 @@ describe('isSpecType', () => {
6969
expect(isSpecType.Tool({ name: 'echo' })).toBe(false);
7070
});
7171

72+
it('Tool — SEP-2106 widened outputSchema (override applied)', () => {
73+
const arrayOut = { name: 'nums', inputSchema: { type: 'object' }, outputSchema: { type: 'array', items: { type: 'number' } } };
74+
expect(isSpecType.Tool(arrayOut)).toBe(true);
75+
expect(isSpecType.ListToolsResult({ tools: [arrayOut] })).toBe(true);
76+
// type-level: the public Tool['outputSchema'] is the loose SEP-2106 shape, not pinned to type:'object'.
77+
const t: Tool = {
78+
name: 'nums',
79+
inputSchema: { type: 'object' },
80+
outputSchema: { type: 'array', items: { type: 'number' } }
81+
};
82+
void t;
83+
});
84+
7285
it('ResourceTemplate — accepts valid, rejects missing uriTemplate', () => {
7386
expect(isSpecType.ResourceTemplate({ name: 'r', uriTemplate: 'file:///{path}' })).toBe(true);
7487
expect(isSpecType.ResourceTemplate({ name: 'r' })).toBe(false);
@@ -162,7 +175,11 @@ describe('SpecTypeName / SpecTypes (type-level)', () => {
162175
type DeclaresResultType = 'resultType' extends KnownKeys<SpecTypes['CallToolResult']> ? true : false;
163176
expectTypeOf<DeclaresResultType>().toEqualTypeOf<false>();
164177
expectTypeOf<SpecTypes['ContentBlock']>().toEqualTypeOf<ContentBlock>();
165-
expectTypeOf<SpecTypes['Tool']>().toEqualTypeOf<Tool>();
178+
// SEP-2106: the public `Tool` widens `outputSchema` to a loose JSON Schema document (the
179+
// 2025 runtime schema retains `type:'object'`). Same bidirectional pin as CallToolResult —
180+
// spec→public holds directly; public→spec holds once outputSchema is re-narrowed.
181+
expectTypeOf<SpecTypes['Tool']>().toMatchTypeOf<Tool>();
182+
expectTypeOf<Tool & { outputSchema?: { type: 'object' } }>().toMatchTypeOf<SpecTypes['Tool']>();
166183
expectTypeOf<SpecTypes['Implementation']>().toEqualTypeOf<Implementation>();
167184
expectTypeOf<SpecTypes['JSONRPCRequest']>().toEqualTypeOf<JSONRPCRequest>();
168185
expectTypeOf<SpecTypes['OAuthTokens']>().toEqualTypeOf<OAuthTokens>();

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ describe('standardSchemaToJsonSchema', () => {
7777
expect(result.type).toBe('object');
7878
});
7979

80+
test('union of discriminatedUnions (typeless anyOf of typeless oneOf-of-objects) is stamped type:object', () => {
81+
// Members are themselves typeless object-shaped compositions; the check recurses.
82+
const a = z.discriminatedUnion('kind', [z.object({ kind: z.literal('a'), x: z.number() }), z.object({ kind: z.literal('b') })]);
83+
const b = z.discriminatedUnion('kind', [z.object({ kind: z.literal('c'), y: z.string() }), z.object({ kind: z.literal('d') })]);
84+
const result = standardSchemaToJsonSchema(z.union([a, b]), 'output');
85+
expect(result.type).toBe('object');
86+
});
87+
8088
test('mixed union (object + primitive) is NOT stamped', () => {
8189
const schema = z.union([z.object({ a: z.number() }), z.string()]);
8290
const result = standardSchemaToJsonSchema(schema, 'output');

packages/server/src/server/mcp.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ import { getCompleter, isCompletable } from './completable.js';
5050
import type { ServerOptions } from './server.js';
5151
import { Server } from './server.js';
5252

53+
/**
54+
* Tool names whose non-object `outputSchema` has already been warned about. Module-level so the
55+
* warn-once dedup survives across the per-request `createMcpHandler` factory model (which builds a
56+
* fresh `McpServer` per request).
57+
*/
58+
const warnedNonObjectOutput: Set<string> = new Set();
59+
5360
/**
5461
* High-level MCP server that provides a simpler API for working with resources, tools, and prompts.
5562
* For advanced usage (like sending notifications or setting custom request handlers), use the underlying
@@ -82,8 +89,6 @@ export class McpServer {
8289
* per-request-factory `createMcpHandler` model.
8390
*/
8491
private _toolInputSchemaJson: { [name: string]: Record<string, unknown> } = {};
85-
/** Tool names whose non-object `outputSchema` has already been warned about (warn-once per tool). */
86-
private _warnedNonObjectOutput: Set<string> = new Set();
8792

8893
/** Whether the negotiated protocol revision is the modern (≥ 2026-07-28) era. */
8994
private _isModernEra(): boolean {
@@ -92,8 +97,11 @@ export class McpServer {
9297
}
9398

9499
private _warnNonObjectOutputSchemaOnce(name: string): void {
95-
if (this._warnedNonObjectOutput.has(name)) return;
96-
this._warnedNonObjectOutput.add(name);
100+
// Module-level dedup: under the per-request `createMcpHandler` factory model a fresh
101+
// McpServer is constructed per request, so an instance-level Set would re-warn on every
102+
// request. The dedup key is the tool name — process-global, mirrors `warnedZodFallback`.
103+
if (warnedNonObjectOutput.has(name)) return;
104+
warnedNonObjectOutput.add(name);
97105
console.warn(
98106
`[mcp-sdk] tool '${name}' has a non-object outputSchema (SEP-2106). This is dropped from the ` +
99107
`legacy (≤ 2025-11-25) tools/list projection so 2025 clients can still call the tool; ` +
@@ -221,7 +229,7 @@ export class McpServer {
221229
if (isNonObjectJsonSchemaRoot(json) && !this._isModernEra()) {
222230
this._warnNonObjectOutputSchemaOnce(name);
223231
} else {
224-
toolDefinition.outputSchema = json as Tool['outputSchema'];
232+
toolDefinition.outputSchema = json;
225233
}
226234
}
227235

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
/**
2-
* Customisation entry point for the AJV validator. Re-exports `Ajv` + `addFormats` from the
3-
* SDK's bundled copy, so customising the validator needs no extra installs.
2+
* Customisation entry point for the AJV validator. Re-exports `Ajv2020`, `Ajv`, and `addFormats`
3+
* from the SDK's bundled copy, so customising the validator needs no extra installs. Use `Ajv2020`
4+
* for a custom instance — `Ajv` is the draft-07 class (kept for opting back to the pre-SEP-1613
5+
* default) and would silently downgrade dialect.
46
*
57
* @example
68
* ```ts
7-
* import { Ajv, addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv';
9+
* import { Ajv2020, addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv';
810
*
9-
* const ajv = new Ajv({ strict: true, allErrors: true });
11+
* const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true });
1012
* addFormats(ajv);
1113
* const validator = new AjvJsonSchemaValidator(ajv);
1214
* ```
1315
*/
14-
export { addFormats, Ajv, AjvJsonSchemaValidator } from '@modelcontextprotocol/core/validators/ajv';
16+
export { addFormats, Ajv, Ajv2020, AjvJsonSchemaValidator } from '@modelcontextprotocol/core/validators/ajv';

0 commit comments

Comments
 (0)