From e598b488fe5aff4c65cc96c048ded67ba281fb04 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 23 Jun 2026 15:45:20 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat(core):=20SEP-2106=20=E2=80=94=20widen?= =?UTF-8?q?=20public=20schemas=20(structuredContent:=20unknown,=20outputSc?= =?UTF-8?q?hema=20open)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2025 wire codec is no longer parsed against types/schemas.ts (the preceding refactor froze the affected schemas in wire/rev2025-11-25/), so the neutral/public layer can now widen to the SEP-2106 shapes directly: - CallToolResult.structuredContent / ToolResultContent.structuredContent → z.unknown() (any JSON value). - Tool.outputSchema → z.looseObject({$schema?: string}) (any JSON Schema document, not just type:'object'). The compositions (ListToolsResult, CompatibilityCallToolResult, SamplingMessageContentBlock, SamplingMessage, CreateMessageResultWithTools) widen automatically. types.ts, guards.ts and specTypeSchema.ts are unchanged — the public TypeScript types are inferred straight from the widened schemas with no overlay machinery. standardSchemaToJsonSchema(_, 'output') now permits non-object roots and only stamps type:'object' on a typeless root that is provably object-shaped (properties/required at the root, or every oneOf/anyOf/allOf member is object-typed) so existing zod outputSchemas keep producing valid 2025 wire data where they always did. The 2025 spec-anchor parity pins for the affected names now target the frozen wire schemas; the new publicTypeShapes test pins the public widening and re-asserts the frozen 2025 reject behavior. --- packages/core/src/types/schemas.ts | 33 +++++---- packages/core/src/util/standardSchema.ts | 52 +++++++++++-- .../core/src/wire/rev2025-11-25/wireTypes.ts | 19 ++++- .../core/test/spec.types.2025-11-25.test.ts | 20 +++-- packages/core/test/types.test.ts | 4 +- .../core/test/types/publicTypeShapes.test.ts | 73 +++++++++++++++++++ test/e2e/scenarios/sampling.test.ts | 10 +-- 7 files changed, 178 insertions(+), 33 deletions(-) create mode 100644 packages/core/test/types/publicTypeShapes.test.ts diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index 91b61dd25d..284eb48f30 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -1314,18 +1314,14 @@ export const ToolSchema = z.object({ }) .catchall(z.unknown()), /** - * An optional JSON Schema 2020-12 object defining the structure of the tool's output + * An optional JSON Schema 2020-12 document describing the structure of the tool's output * returned in the `structuredContent` field of a `CallToolResult`. - * Must have `type: 'object'` at the root level per MCP spec. + * + * SEP-2106: any JSON Schema root is permitted (e.g. `type:'array'`, `oneOf`, `$ref`). + * The 2025-11-25 wire parse retains the `type:'object'` constraint via the frozen schema in + * `wire/rev2025-11-25/schemas.ts`; this neutral/public schema widens. */ - outputSchema: z - .object({ - type: z.literal('object'), - properties: z.record(z.string(), JSONValueSchema).optional(), - required: z.array(z.string()).optional() - }) - .catchall(z.unknown()) - .optional(), + outputSchema: z.looseObject({ $schema: z.string().optional() }).optional(), /** * Optional additional tool information. */ @@ -1369,11 +1365,16 @@ export const CallToolResultSchema = ResultSchema.extend({ content: z.array(ContentBlockSchema), /** - * An object containing structured tool output. + * Structured tool output. * - * If the `Tool` defines an outputSchema, this field MUST be present in the result, and contain a JSON object that matches the schema. + * If the `Tool` defines an `outputSchema`, this field MUST be present in the result and + * contain a JSON value that matches the schema. + * + * SEP-2106: any JSON value is permitted (arrays, primitives, `null`). Narrow before property + * access. The 2025-11-25 wire parse retains the object-only constraint via the frozen schema + * in `wire/rev2025-11-25/schemas.ts`; this neutral/public schema widens. */ - structuredContent: z.record(z.string(), z.unknown()).optional(), + structuredContent: z.unknown().optional(), /** * Whether the tool call ended in an error. @@ -1594,7 +1595,11 @@ export const ToolResultContentSchema = z.object({ type: z.literal('tool_result'), toolUseId: z.string().describe('The unique identifier for the corresponding tool call.'), content: z.array(ContentBlockSchema), - structuredContent: z.object({}).loose().optional(), + /** + * SEP-2106: any JSON value is permitted. The 2025-11-25 wire parse retains the object-only + * constraint via the frozen schema in `wire/rev2025-11-25/schemas.ts`. + */ + structuredContent: z.unknown().optional(), isError: z.boolean().optional(), /** diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index b938885de0..df8ecc2881 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -167,12 +167,13 @@ let warnedZodFallback = false; /** * Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema. * - * MCP requires `type: "object"` at the root of tool inputSchema/outputSchema and - * prompt argument schemas. Zod's discriminated unions emit `{oneOf: [...]}` without - * a top-level `type`, so this function defaults `type` to `"object"` when absent. - * - * Throws if the schema has an explicit non-object `type` (e.g. `z.string()`), - * since that cannot satisfy the MCP spec. + * MCP requires `type: "object"` at the root of tool `inputSchema` and prompt + * argument schemas; `outputSchema` may have any JSON Schema root (SEP-2106). + * Zod's discriminated unions emit `{oneOf: [...]}` without a top-level `type`, + * so for `io: 'input'` this function defaults `type` to `"object"` when absent + * and throws on an explicit non-object `type` (e.g. `z.string()`). For + * `io: 'output'` a non-object root is returned as-is; the `"object"` default is + * applied only when the root is provably object-shaped. */ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'input' | 'output' = 'input'): Record { const std = schema['~standard']; @@ -204,6 +205,20 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in `Upgrade to a version that does, or wrap your JSON Schema with fromJsonSchema().` ); } + if (io === 'output') { + // SEP-2106: outputSchema may have any JSON Schema root. An explicit `type` (object or + // not) is returned as-is. A typeless root only gets `type:'object'` defaulted when it is + // PROVABLY object-shaped — either it carries object keywords at the root, or every + // member of a root `oneOf`/`anyOf`/`allOf` is itself `type:'object'` (the + // `z.discriminatedUnion(...)`, `z.union([z.object(...), ...])`, `z.intersection(...)` + // cases). Those pre-SEP schemas were valid 2025 wire data via the unconditional stamp, + // so the stamp is kept where it is provably safe. A typeless root that is NOT provably + // object-shaped (e.g. `z.union([z.string(), z.number()])` → `{anyOf:[…]}`) is returned + // as-is — stamping there would be self-contradictory. The legacy projection drop gates + // anything that does not end up `type:'object'`. + if (result.type !== undefined) return result; + return isProvablyObjectShapedRoot(result) ? { type: 'object', ...result } : result; + } if (result.type !== undefined && result.type !== 'object') { throw new Error( `MCP tool and prompt schemas must describe objects (got type: ${JSON.stringify(result.type)}). ` + @@ -213,6 +228,31 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in return { type: 'object', ...result }; } +/** + * A typeless JSON Schema root is "provably object-shaped" when either it carries object keywords + * directly (`properties`/`patternProperties`/`additionalProperties`/`required`), or it is a + * composition (`oneOf`/`anyOf`/`allOf`) whose every member is itself `type:'object'` or recursively + * provably object-shaped (e.g. a nested `discriminatedUnion`). `$ref` is not followed. Used to + * decide whether stamping `type:'object'` is safe (redundant-but-valid) versus self-contradictory. + */ +function isProvablyObjectShapedRoot(schema: Record): boolean { + if ('properties' in schema || 'patternProperties' in schema || 'additionalProperties' in schema || 'required' in schema) { + return true; + } + for (const key of ['oneOf', 'anyOf', 'allOf'] as const) { + const members = schema[key]; + if (Array.isArray(members) && members.length > 0) { + return members.every( + m => + m !== null && + typeof m === 'object' && + ((m as Record).type === 'object' || isProvablyObjectShapedRoot(m as Record)) + ); + } + } + return false; +} + // Validation export type StandardSchemaValidationResult = { success: true; data: T } | { success: false; error: string }; diff --git a/packages/core/src/wire/rev2025-11-25/wireTypes.ts b/packages/core/src/wire/rev2025-11-25/wireTypes.ts index f1c116ccad..c718ac58fa 100644 --- a/packages/core/src/wire/rev2025-11-25/wireTypes.ts +++ b/packages/core/src/wire/rev2025-11-25/wireTypes.ts @@ -23,11 +23,18 @@ * - `extensions` capability key: 2026-only; absent from the 2025 wire view. * - `CreateMessageRequestParams.metadata`: 2025 wire `object`; neutral * `JSONObject`. + * - SEP-2106: `CallToolResult.structuredContent` / the `tool_result` + * sampling-content arm's `structuredContent` / `Tool.outputSchema`: 2025 + * wire object-only; neutral `unknown` / open JSON Schema document. The + * 2025 wire-exact shapes are inferred directly from the FROZEN copies in + * `./schemas.ts` (Wire2025SamplingMessage, Wire2025CallToolResult). * - `PromptArgument.title` / `PromptReference.title`: present on the 2025 * wire (BaseMetadata); the neutral schemas do not declare it and the * strip-mode parse drops it (PRE-EXISTING runtime gap, recorded in the * project baseline-bug log — do not silently change parse behavior here). */ +import type * as z4 from 'zod/v4'; + import type { CallToolRequest, CancelTaskRequest, @@ -59,6 +66,10 @@ import type { Tool, UnsubscribeRequest } from '../../types/types.js'; +import type { + CallToolResultSchema as Frozen2025CallToolResultSchema, + SamplingMessageSchema as Frozen2025SamplingMessageSchema +} from './schemas.js'; /** The 2025 anchor types blob values as bare `object`. */ type ObjectMap = { [key: string]: object }; @@ -121,9 +132,15 @@ export type Wire2025InitializeRequest = OmitKnown & export type Wire2025InitializeResult = OmitKnown & { capabilities: Wire2025ServerCapabilities }; -export type Wire2025CreateMessageRequestParams = OmitKnown & { +/** SEP-2106 adjudication: inferred from the FROZEN 2025 schema (object-only `structuredContent`). */ +export type Wire2025SamplingMessage = z4.infer; +/** SEP-2106 adjudication: inferred from the FROZEN 2025 schema (record `structuredContent`). */ +export type Wire2025CallToolResult = z4.infer; + +export type Wire2025CreateMessageRequestParams = OmitKnown & { metadata?: object; tools?: Wire2025Tool[]; + messages: Wire2025SamplingMessage[]; }; export type Wire2025CreateMessageRequest = OmitKnown & { params: Wire2025CreateMessageRequestParams }; diff --git a/packages/core/test/spec.types.2025-11-25.test.ts b/packages/core/test/spec.types.2025-11-25.test.ts index 76d6d7bfab..9bbf5606a6 100644 --- a/packages/core/test/spec.types.2025-11-25.test.ts +++ b/packages/core/test/spec.types.2025-11-25.test.ts @@ -39,6 +39,16 @@ import type { } from '../src/wire/rev2025-11-25/wireTypes.js'; import type * as z4 from 'zod/v4'; +// SEP-2106 adjudication: the public/neutral SDK types widen `structuredContent` (`unknown`) +// and `outputSchema` (open JSON Schema document); the 2025 wire-exact pins target the FROZEN +// copies in `wire/rev2025-11-25/schemas.ts`. The public widening is pinned in +// `types/publicTypeShapes.test.ts`. +type Wire2025CallToolResult = z4.infer; +type Wire2025SamplingMessage = z4.infer; +type Wire2025CreateMessageResultWithTools = z4.infer; +type Wire2025ToolResultContent = z4.infer; +type Wire2025SamplingMessageContentBlock = z4.infer; + type Wire2025ClientRequest = z4.infer; type Wire2025ClientNotification = z4.infer; type Wire2025ClientResult = z4.infer; @@ -284,7 +294,7 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - CallToolResult: (sdk: SDKTypes.CallToolResult, spec: SpecTypes.CallToolResult) => { + CallToolResult: (sdk: Wire2025CallToolResult, spec: SpecTypes.CallToolResult) => { sdk = spec; spec = sdk; }, @@ -321,11 +331,11 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - SamplingMessage: (sdk: SDKTypes.SamplingMessage, spec: SpecTypes.SamplingMessage) => { + SamplingMessage: (sdk: Wire2025SamplingMessage, spec: SpecTypes.SamplingMessage) => { sdk = spec; spec = sdk; }, - CreateMessageResult: (sdk: SDKTypes.CreateMessageResultWithTools, spec: SpecTypes.CreateMessageResult) => { + CreateMessageResult: (sdk: Wire2025CreateMessageResultWithTools, spec: SpecTypes.CreateMessageResult) => { sdk = spec; spec = sdk; }, @@ -564,11 +574,11 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - ToolResultContent: (sdk: SDKTypes.ToolResultContent, spec: SpecTypes.ToolResultContent) => { + ToolResultContent: (sdk: Wire2025ToolResultContent, spec: SpecTypes.ToolResultContent) => { sdk = spec; spec = sdk; }, - SamplingMessageContentBlock: (sdk: SDKTypes.SamplingMessageContentBlock, spec: SpecTypes.SamplingMessageContentBlock) => { + SamplingMessageContentBlock: (sdk: Wire2025SamplingMessageContentBlock, spec: SpecTypes.SamplingMessageContentBlock) => { sdk = spec; spec = sdk; }, diff --git a/packages/core/test/types.test.ts b/packages/core/test/types.test.ts index 70b0b02a82..c56f83990b 100644 --- a/packages/core/test/types.test.ts +++ b/packages/core/test/types.test.ts @@ -497,7 +497,7 @@ describe('Types', () => { expect(result.success).toBe(false); }); - test('should still require type: object at root for outputSchema', () => { + test('SEP-2106: outputSchema accepts any JSON Schema root (the public schema; the 2025 wire schema still rejects)', () => { const tool = { name: 'test', inputSchema: { type: 'object' }, @@ -506,7 +506,7 @@ describe('Types', () => { } }; const result = ToolSchema.safeParse(tool); - expect(result.success).toBe(false); + expect(result.success).toBe(true); }); test('should accept simple minimal schema (backward compatibility)', () => { diff --git a/packages/core/test/types/publicTypeShapes.test.ts b/packages/core/test/types/publicTypeShapes.test.ts new file mode 100644 index 0000000000..5358e80613 --- /dev/null +++ b/packages/core/test/types/publicTypeShapes.test.ts @@ -0,0 +1,73 @@ +/** + * SEP-2106 public-type pins. + * + * The neutral/public schemas in `types/schemas.ts` widen `structuredContent` (any JSON value) + * and `Tool.outputSchema` (any JSON Schema document). The 2025 wire-parse contract is preserved + * via the FROZEN copies in `wire/rev2025-11-25/schemas.ts`. This file pins both: + * - the public TypeScript types carry the widened shapes (type-level pins); + * - the frozen 2025 wire schemas still REJECT the widened vocabulary (runtime pins). + * + * The 2025 spec-anchor parity for these names lives in `spec.types.2025-11-25.test.ts` and + * targets the frozen wire schemas, not the public types. + */ +import { describe, expect, expectTypeOf, it } from 'vitest'; + +import type { + CallToolResult, + CompatibilityCallToolResult, + CreateMessageResultWithTools, + ListToolsResult, + SamplingMessage, + SamplingMessageContentBlock, + Tool, + ToolResultContent +} from '../../src/types/types.js'; +import { + CallToolResultSchema as Wire2025CallToolResultSchema, + ToolSchema as Wire2025ToolSchema +} from '../../src/wire/rev2025-11-25/schemas.js'; + +describe('SEP-2106 public-type widening', () => { + it('CallToolResult.structuredContent is unknown', () => { + expectTypeOf().toEqualTypeOf(); + }); + it('CompatibilityCallToolResult.structuredContent is unknown (on the modern arm)', () => { + expectTypeOf['structuredContent']>().toEqualTypeOf(); + }); + it('ToolResultContent.structuredContent is unknown', () => { + expectTypeOf().toEqualTypeOf(); + }); + it('SamplingMessageContentBlock tool_result arm carries unknown structuredContent', () => { + expectTypeOf['structuredContent']>().toEqualTypeOf(); + }); + it('SamplingMessage.content composes the widened tool_result arm', () => { + type Block = Extract, { type: 'tool_result' }>; + expectTypeOf().toEqualTypeOf(); + }); + it('CreateMessageResultWithTools.content composes the widened tool_result arm', () => { + type Block = Extract, { type: 'tool_result' }>; + expectTypeOf().toEqualTypeOf(); + }); + it('Tool.outputSchema is an open JSON Schema document', () => { + expectTypeOf>().toEqualTypeOf<{ $schema?: string; [k: string]: unknown }>(); + }); + it('ListToolsResult.tools composes the widened Tool', () => { + expectTypeOf>().toEqualTypeOf<{ + $schema?: string; + [k: string]: unknown; + }>(); + }); +}); + +describe('Q10-L2: frozen 2025 wire schemas still reject SEP-2106 vocabulary', () => { + it('Wire2025 CallToolResultSchema rejects non-object structuredContent', () => { + expect(Wire2025CallToolResultSchema.safeParse({ content: [], structuredContent: [1] }).success).toBe(false); + expect(Wire2025CallToolResultSchema.safeParse({ content: [], structuredContent: 0 }).success).toBe(false); + expect(Wire2025CallToolResultSchema.safeParse({ content: [], structuredContent: { ok: true } }).success).toBe(true); + }); + it("Wire2025 ToolSchema rejects non-type:'object' outputSchema", () => { + const base = { name: 't', inputSchema: { type: 'object' } }; + expect(Wire2025ToolSchema.safeParse({ ...base, outputSchema: { type: 'array' } }).success).toBe(false); + expect(Wire2025ToolSchema.safeParse({ ...base, outputSchema: { type: 'object' } }).success).toBe(true); + }); +}); diff --git a/test/e2e/scenarios/sampling.test.ts b/test/e2e/scenarios/sampling.test.ts index f251a9ef5f..75e4fc52cf 100644 --- a/test/e2e/scenarios/sampling.test.ts +++ b/test/e2e/scenarios/sampling.test.ts @@ -202,7 +202,7 @@ verifies('sampling:error:user-rejected', async ({ transport }: TestArgs) => { const r = await client.callTool({ name: 'sampling-passthrough', arguments: { messages: [], maxTokens: 10 } }); expect(r.structuredContent).toMatchObject({ ok: false, code: -1 }); - expect(r.structuredContent?.message).toMatch(/User rejected sampling request/); + expect((r.structuredContent as { message?: unknown }).message).toMatch(/User rejected sampling request/); }); verifies('sampling:message:content-cardinality', async ({ transport }: TestArgs) => { @@ -340,7 +340,7 @@ verifies('sampling:tool-result:no-mixed-content', async ({ transport }: TestArgs }); expect(r.structuredContent).toMatchObject({ ok: false, code: ProtocolErrorCode.InvalidParams }); - expect(r.structuredContent?.message).toMatch(/tool.?result/i); + expect((r.structuredContent as { message?: unknown }).message).toMatch(/tool.?result/i); }); verifies('sampling:tool-use:result-balance', async ({ transport }: TestArgs) => { @@ -425,7 +425,7 @@ verifies('sampling:tools:server-gated-by-capability', async ({ transport }: Test }); expect(withTools.structuredContent).toMatchObject({ ok: false }); - expect(withTools.structuredContent?.message).toMatch(/sampling.*tools/i); + expect((withTools.structuredContent as { message?: unknown }).message).toMatch(/sampling.*tools/i); expect(received).toHaveLength(0); const withChoice = await client.callTool({ @@ -434,7 +434,7 @@ verifies('sampling:tools:server-gated-by-capability', async ({ transport }: Test }); expect(withChoice.structuredContent).toMatchObject({ ok: false }); - expect(withChoice.structuredContent?.message).toMatch(/sampling.*tools/i); + expect((withChoice.structuredContent as { message?: unknown }).message).toMatch(/sampling.*tools/i); expect(received).toHaveLength(0); const empty = await client.callTool({ @@ -443,7 +443,7 @@ verifies('sampling:tools:server-gated-by-capability', async ({ transport }: Test }); expect(empty.structuredContent).toMatchObject({ ok: false }); - expect(empty.structuredContent?.message).toMatch(/sampling.*tools/i); + expect((empty.structuredContent as { message?: unknown }).message).toMatch(/sampling.*tools/i); expect(received).toHaveLength(0); }); From 2926f4c27c0a09d4fe4e529fc8a0d1bc19742cc5 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 23 Jun 2026 15:47:38 +0000 Subject: [PATCH 2/6] =?UTF-8?q?feat(core):=20SEP-2106=20validator=20?= =?UTF-8?q?=E2=80=94=20Ajv2020=20default=20+=20graceful=20unsupported-$sch?= =?UTF-8?q?ema=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default Ajv instance is now Ajv2020 (was draft-07 Ajv), so 2020-12 keywords (prefixItems, unevaluatedProperties, …) are enforced and tool output schemas — which SEP-2106 lets describe any JSON value — validate under the spec-declared dialect (SEP-1613). Both built-in providers add a single inline check before compile: if the schema declares a $schema URI and it isn't a 2020-12 URI, throw a plain Error with a clear message instead of letting the engine crash on an opaque internal error or silently mis-validate. Passing a custom Ajv instance / an explicit {draft} skips the check (caller owns dialect). The Ajv re-export stays for opt-back to the v1-equivalent draft-07 construction; Ajv2020 is not re-exported (its type graph tips downstream declaration bundling — #2339). --- .../src/validators/ajvProvider.examples.ts | 22 +++-- packages/core/src/validators/ajvProvider.ts | 86 ++++++++++++++++--- .../core/src/validators/cfWorkerProvider.ts | 44 ++++++++-- .../core/test/validators/validators.test.ts | 60 ++++++++++++- 4 files changed, 186 insertions(+), 26 deletions(-) diff --git a/packages/core/src/validators/ajvProvider.examples.ts b/packages/core/src/validators/ajvProvider.examples.ts index abf8d1572a..7258c20291 100644 --- a/packages/core/src/validators/ajvProvider.examples.ts +++ b/packages/core/src/validators/ajvProvider.examples.ts @@ -7,7 +7,9 @@ * @module */ -import { addFormats, Ajv, AjvJsonSchemaValidator } from './ajvProvider.js'; +import { Ajv2020 } from 'ajv/dist/2020.js'; + +import { addFormats, AjvJsonSchemaValidator } from './ajvProvider.js'; /** * Example: Default AJV instance. @@ -21,10 +23,17 @@ function AjvJsonSchemaValidator_default() { /** * Example: Custom AJV instance. + * + * The SDK bundles ajv internally but does not re-export `Ajv2020` (its type graph tips downstream + * declaration bundling — see #2339). To construct a custom 2020-12 instance, add `ajv` to your own + * dependencies (matching the SDK's pinned version) and `import { Ajv2020 } from 'ajv/dist/2020.js'` + * so the custom instance keeps validating JSON Schema 2020-12 (SEP-1613). Passing `new Ajv(...)` + * (the draft-07 class) would silently downgrade dialect. */ function AjvJsonSchemaValidator_customInstance() { //#region AjvJsonSchemaValidator_customInstance - const ajv = new Ajv({ strict: true, allErrors: true }); + // import { Ajv2020 } from 'ajv/dist/2020.js'; + const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true }); const validator = new AjvJsonSchemaValidator(ajv); //#endregion AjvJsonSchemaValidator_customInstance return validator; @@ -33,12 +42,15 @@ function AjvJsonSchemaValidator_customInstance() { /** * Example: Custom AJV instance with formats registered. * - * `Ajv` and `addFormats` are re-exported from this module so customising the validator - * requires no extra `package.json` dependencies — both come from the SDK's bundled copy. + * `addFormats` is re-exported from this module. The SDK bundles ajv internally but does not + * re-export `Ajv2020` (its type graph tips downstream declaration bundling — see #2339). To + * construct a custom 2020-12 instance, add `ajv` to your own dependencies (matching the SDK's + * pinned version) and `import { Ajv2020 } from 'ajv/dist/2020.js'`. */ function AjvJsonSchemaValidator_withFormats() { //#region AjvJsonSchemaValidator_withFormats - const ajv = new Ajv({ strict: true, allErrors: true }); + // import { Ajv2020 } from 'ajv/dist/2020.js'; + const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true }); addFormats(ajv); const validator = new AjvJsonSchemaValidator(ajv); //#endregion AjvJsonSchemaValidator_withFormats diff --git a/packages/core/src/validators/ajvProvider.ts b/packages/core/src/validators/ajvProvider.ts index f62a8469ae..59c7c87952 100644 --- a/packages/core/src/validators/ajvProvider.ts +++ b/packages/core/src/validators/ajvProvider.ts @@ -2,11 +2,21 @@ * AJV-based JSON Schema validator provider */ -import { Ajv } from 'ajv'; +import { Ajv2020 } from 'ajv/dist/2020.js'; import _addFormats from 'ajv-formats'; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types.js'; +/** + * Canonical 2020-12 `$schema` URIs (http + https variants, trailing-`#` stripped). When a schema + * declares anything else, the default provider throws a plain `Error` with a clear message rather + * than letting the engine crash on an opaque internal error or silently mis-validate. + */ +const DRAFT_2020_12_URIS: ReadonlySet = new Set([ + 'https://json-schema.org/draft/2020-12/schema', + 'http://json-schema.org/draft/2020-12/schema' +]); + /** Structural subset of the AJV interface used by {@link AjvJsonSchemaValidator}. */ interface AjvLike { compile: (schema: unknown) => AjvValidateFunction; @@ -21,17 +31,16 @@ interface AjvValidateFunction { errors?: any; } -function createDefaultAjvInstance(): Ajv { - const ajv = new Ajv({ +const addFormats = _addFormats as unknown as typeof _addFormats.default; + +function createDefaultAjvInstance(): AjvLike { + const ajv = new Ajv2020({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }); - - const addFormats = _addFormats as unknown as typeof _addFormats.default; addFormats(ajv); - return ajv; } @@ -39,6 +48,14 @@ function createDefaultAjvInstance(): Ajv { * AJV-backed JSON Schema validator. See `@modelcontextprotocol/{client,server}/validators/ajv` * for the customisation entry point (re-exports `Ajv` and `addFormats` from the bundled copy). * + * Default validates as **JSON Schema 2020-12** (SEP-1613). Schemas declaring a different + * `$schema` are rejected with a plain `Error`; pass a pre-configured Ajv instance to validate + * other dialects. The SDK bundles ajv internally but does not re-export `Ajv2020` (its type + * graph tips downstream declaration bundling — see #2339). To construct a custom 2020-12 + * instance, add `ajv` to your own dependencies (matching the SDK's pinned version) and + * `import { Ajv2020 } from 'ajv/dist/2020.js'` — `new Ajv(...)` is the draft-07 class and would + * silently downgrade dialect. + * * @example Use with default configuration * ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_default" * const validator = new AjvJsonSchemaValidator(); @@ -46,31 +63,54 @@ function createDefaultAjvInstance(): Ajv { * * @example Use with a custom AJV instance * ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_customInstance" - * const ajv = new Ajv({ strict: true, allErrors: true }); + * // import { Ajv2020 } from 'ajv/dist/2020.js'; + * const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true }); * const validator = new AjvJsonSchemaValidator(ajv); * ``` * * @example Register ajv-formats * ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_withFormats" - * const ajv = new Ajv({ strict: true, allErrors: true }); + * // import { Ajv2020 } from 'ajv/dist/2020.js'; + * const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true }); * addFormats(ajv); * const validator = new AjvJsonSchemaValidator(ajv); * ``` */ export class AjvJsonSchemaValidator implements jsonSchemaValidator { - private _ajv: AjvLike; + private readonly _ajv: AjvLike; + /** True iff the constructor received a caller-supplied engine; the `$schema` check is skipped. */ + private readonly _userAjv: boolean; /** - * @param ajv - Optional pre-configured AJV-compatible instance. If omitted, a default instance is - * created with `strict: false`, `validateFormats: true`, `validateSchema: false`, `allErrors: true`, - * and `ajv-formats` registered. The parameter is typed structurally so consumers who don't pass - * an instance need not have `ajv` installed. + * @param ajv - Optional pre-configured AJV-compatible instance. When supplied, this instance is + * used for **every** schema regardless of its declared `$schema` (the caller owns dialect + * choice). When omitted, the provider constructs a single `Ajv2020` instance with + * `strict: false`, `validateFormats: true`, `validateSchema: false`, `allErrors: true`, and + * `ajv-formats` registered. The parameter is typed structurally so consumers who don't pass an + * instance need not have `ajv` installed. */ constructor(ajv?: AjvLike) { + this._userAjv = ajv !== undefined; this._ajv = ajv ?? createDefaultAjvInstance(); } getValidator(schema: JsonSchemaType): JsonSchemaValidator { + // Caller supplied a specific engine — do not second-guess by `$schema` + // (bring-your-own-validator means bring-your-own-dialect). + if ( + !this._userAjv && + '$schema' in schema && + typeof schema.$schema === 'string' && + !DRAFT_2020_12_URIS.has(schema.$schema.replace(/#$/, '')) + ) { + const declared = schema.$schema.slice(0, 200); + throw new Error( + `JSON Schema declares an unsupported dialect ("$schema": "${declared}"). ` + + `The default validator supports JSON Schema 2020-12 only; pass a pre-configured ` + + `Ajv instance to AjvJsonSchemaValidator(ajv) to validate other dialects.` + ); + } + const ajvValidator = '$id' in schema && typeof schema.$id === 'string' ? (this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema)) @@ -94,6 +134,24 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator { } } +/** + * Draft-07 AJV class, re-exported for consumers who need to opt back to the pre-SEP-1613 default. + * The full v1-equivalent construction is: + * + * ```ts + * const ajv = new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }); + * addFormats(ajv); + * new AjvJsonSchemaValidator(ajv); + * ``` + * + * (omitting `validateSchema: false` makes a 2020-12-stamped `$schema` fail with an opaque + * "no schema with key or ref …" engine error; omitting `addFormats` silently drops `format` + * validation that the v1 default had). + * + * The SDK bundles ajv internally but does not re-export `Ajv2020` (its type graph tips downstream + * declaration bundling — see #2339). To construct a custom 2020-12 instance, add `ajv` to your own + * dependencies (matching the SDK's pinned version) and `import { Ajv2020 } from 'ajv/dist/2020.js'`. + */ export { Ajv } from 'ajv'; /** `ajv-formats` default export, normalised through the CJS/ESM interop wrapper. */ -export const addFormats = _addFormats as unknown as typeof _addFormats.default; +export { addFormats }; diff --git a/packages/core/src/validators/cfWorkerProvider.ts b/packages/core/src/validators/cfWorkerProvider.ts index 6fcc3d507e..dde545fe59 100644 --- a/packages/core/src/validators/cfWorkerProvider.ts +++ b/packages/core/src/validators/cfWorkerProvider.ts @@ -17,11 +17,24 @@ import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSche */ export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; +/** + * Canonical 2020-12 `$schema` URIs (http + https variants, trailing-`#` stripped). When a schema + * declares anything else and no `{draft}` is forced, the provider throws a plain `Error`. + */ +const DRAFT_2020_12_URIS: ReadonlySet = new Set([ + 'https://json-schema.org/draft/2020-12/schema', + 'http://json-schema.org/draft/2020-12/schema' +]); + /** * `@cfworker/json-schema`-backed JSON Schema validator. See * `@modelcontextprotocol/{client,server}/validators/cf-worker` for the customisation entry point. * - * @example Use with default configuration (draft 2020-12, shortcircuit on) + * Default validates as **JSON Schema 2020-12** (SEP-1613). Schemas declaring a different + * `$schema` are rejected with a plain `Error`. Passing an explicit `draft` to the constructor + * overrides this — that draft is used for every schema regardless of `$schema`. + * + * @example Use with default configuration (2020-12, shortcircuit on) * ```ts source="./cfWorkerProvider.examples.ts#CfWorkerJsonSchemaValidator_default" * const validator = new CfWorkerJsonSchemaValidator(); * ``` @@ -35,19 +48,22 @@ export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; * ``` */ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { - private shortcircuit: boolean; - private draft: CfWorkerSchemaDraft; + private readonly shortcircuit: boolean; + /** Caller-supplied draft; when set, the `$schema` check is skipped (caller owns dialect). */ + private readonly draft?: CfWorkerSchemaDraft; /** * Create a validator * * @param options - Configuration options * @param options.shortcircuit - If `true`, stop validation after first error (default: `true`) - * @param options.draft - JSON Schema draft version to use (default: `'2020-12'`) + * @param options.draft - JSON Schema draft version to force for every schema. When set, the + * `$schema` check is skipped. When omitted, the provider validates as 2020-12 and rejects + * schemas declaring a different `$schema`. */ constructor(options?: { shortcircuit?: boolean; draft?: CfWorkerSchemaDraft }) { this.shortcircuit = options?.shortcircuit ?? true; - this.draft = options?.draft ?? '2020-12'; + this.draft = options?.draft; } /** @@ -59,8 +75,24 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { * @returns A validator function that validates input data */ getValidator(schema: JsonSchemaType): JsonSchemaValidator { + // Caller forced a draft — use it for everything; do not second-guess by `$schema`. + if ( + this.draft === undefined && + '$schema' in schema && + typeof schema.$schema === 'string' && + !DRAFT_2020_12_URIS.has(schema.$schema.replace(/#$/, '')) + ) { + const declared = schema.$schema.slice(0, 200); + throw new Error( + `JSON Schema declares an unsupported dialect ("$schema": "${declared}"). ` + + `The default validator supports JSON Schema 2020-12 only; pass an explicit ` + + `{ draft } to CfWorkerJsonSchemaValidator to validate other dialects.` + ); + } + + const draft = this.draft ?? '2020-12'; // Cast to the cfworker Schema type - our JsonSchemaType is structurally compatible - const validator = new Validator(schema as ConstructorParameters[0], this.draft, this.shortcircuit); + const validator = new Validator(schema as ConstructorParameters[0], draft, this.shortcircuit); return (input: unknown): JsonSchemaValidatorResult => { const result = validator.validate(input); diff --git a/packages/core/test/validators/validators.test.ts b/packages/core/test/validators/validators.test.ts index 6c543cb058..0ccf5ad9e4 100644 --- a/packages/core/test/validators/validators.test.ts +++ b/packages/core/test/validators/validators.test.ts @@ -9,7 +9,7 @@ import path from 'node:path'; import { vi } from 'vitest'; -import { AjvJsonSchemaValidator } from '../../src/validators/ajvProvider.js'; +import { Ajv, AjvJsonSchemaValidator } from '../../src/validators/ajvProvider.js'; import { CfWorkerJsonSchemaValidator } from '../../src/validators/cfWorkerProvider.js'; import type { JsonSchemaType } from '../../src/validators/types.js'; @@ -623,3 +623,61 @@ describe('Missing dependencies', () => { }); }); }); + +/** + * SEP-1613 declares JSON Schema 2020-12 the dialect for tool schemas. The built-in providers + * validate as 2020-12 only: a schema with no `$schema` (or `$schema: …2020-12…`) compiles; a + * schema declaring any other `$schema` is rejected with a clear `Error`. The escape hatch is + * the existing custom-engine constructor (caller-supplied Ajv instance / explicit `{draft}`). + * + * Discriminator: `prefixItems` is a 2020-12 keyword that the draft-07 Ajv class silently + * ignores under `strict:false`, so it proves the default engine is `Ajv2020`. + */ +describe('SEP-1613 $schema dialect handling (2020-12 only)', () => { + const DRAFT_07_URI = 'http://json-schema.org/draft-07/schema#'; + const DRAFT_2020_URI = 'https://json-schema.org/draft/2020-12/schema'; + const prefixItemsSchema = ($schema?: string): JsonSchemaType => ({ + ...($schema ? { $schema } : {}), + type: 'array', + prefixItems: [{ type: 'number' }, { type: 'string' }] + }); + /** Violates `prefixItems` (positions swapped). */ + const PREFIX_ITEMS_BAD: unknown = ['x', 1]; + + describe.each(validators)('$name', ({ provider }) => { + it('default → Ajv2020 / 2020-12 (prefixItems is enforced)', () => { + const v = provider.getValidator(prefixItemsSchema()); + expect(v(PREFIX_ITEMS_BAD).valid).toBe(false); + expect(v([1, 'x']).valid).toBe(true); + }); + + it('$schema: 2020-12 → compiles, prefixItems enforced', () => { + const v = provider.getValidator(prefixItemsSchema(DRAFT_2020_URI)); + expect(v(PREFIX_ITEMS_BAD).valid).toBe(false); + }); + + it('$schema: draft-07 → graceful Error', () => { + expect(() => provider.getValidator(prefixItemsSchema(DRAFT_07_URI))).toThrow(/unsupported dialect.*2020-12 only/); + }); + + it('$schema: 2019-09 → graceful Error', () => { + expect(() => provider.getValidator(prefixItemsSchema('https://json-schema.org/draft/2019-09/schema'))).toThrow( + /unsupported dialect/ + ); + }); + }); + + it('AJV: custom Ajv instance bypasses the $schema check (caller owns dialect)', () => { + // A draft-07 Ajv passed explicitly: even with `$schema: draft-07`, the provider does not + // throw — and `prefixItems` is unknown to draft-07 Ajv and silently ignored. + const draft07 = new Ajv({ strict: false, validateSchema: false, allErrors: true }); + const custom = new AjvJsonSchemaValidator(draft07); + const v = custom.getValidator(prefixItemsSchema(DRAFT_07_URI)); + expect(v(PREFIX_ITEMS_BAD).valid).toBe(true); + }); + + it('CfWorker: explicit {draft} bypasses the $schema check (caller owns dialect)', () => { + const custom = new CfWorkerJsonSchemaValidator({ draft: '7' }); + expect(() => custom.getValidator(prefixItemsSchema(DRAFT_07_URI))).not.toThrow(); + }); +}); From c01bd555c9344234a602a10b103df0d50598802c Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 23 Jun 2026 15:50:21 +0000 Subject: [PATCH 3/6] fix(client): lazy outputSchema compile on response-cache substrate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool whose outputSchema fails to compile (unsupported $schema dialect, invalid pattern regex, unresolvable $ref, or any other engine error) is now surfaced as a per-tool ProtocolError(InvalidParams) on callTool, BEFORE the request is sent — one bad schema no longer poisons every other tool's callTool, and the server-side handler is not executed for nothing. The compile result is held on the response-cache substrate's stamp-keyed name → validator index (an {ok}-discriminated union, not the raw validator) so it inherits that substrate's invalidation lifecycle: list_changed evicts, a refetched tools/list re-derives, resetForReconnect clears. No parallel map; no stale-compile-error bug when the server fixes the tool by removing the schema. The toolDefinition path is compiled in isolation and never enters the cache, so a one-off bad definition cannot poison the listed tool. The HEADER_MISMATCH recovery path now re-resolves the validator against the freshly-fetched entry (the pre-flight one was resolved from the now-evicted cache). structuredContent presence is checked with === undefined (SEP-2106: 0/false/''/null are legal) and validation is skipped on isError. --- packages/client/src/client/client.examples.ts | 10 +- packages/client/src/client/client.ts | 111 ++++++++++++------ packages/client/src/client/responseCache.ts | 17 +-- .../jsonSchemaValidatorOverride.test.ts | 83 +++++++++++++ 4 files changed, 177 insertions(+), 44 deletions(-) diff --git a/packages/client/src/client/client.examples.ts b/packages/client/src/client/client.examples.ts index 85f815c189..dbc4ce00cf 100644 --- a/packages/client/src/client/client.examples.ts +++ b/packages/client/src/client/client.examples.ts @@ -105,9 +105,13 @@ async function Client_callTool_structuredOutput(client: Client) { arguments: { weightKg: 70, heightM: 1.75 } }); - // Machine-readable output for the client application - if (result.structuredContent) { - console.log(result.structuredContent); // e.g. { bmi: 22.86 } + // Machine-readable output for the client application. SEP-2106: structuredContent is + // `unknown` (any JSON value). Check for presence with `!== undefined` and narrow before use. + if (result.structuredContent !== undefined) { + const sc: unknown = result.structuredContent; // e.g. { bmi: 22.86 } + if (typeof sc === 'object' && sc !== null && 'bmi' in sc) { + console.log(sc.bmi); + } } //#endregion Client_callTool_structuredOutput } diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 1465f98e38..dc4268ee51 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -430,6 +430,19 @@ interface ListenStateEntry { settle: (outcome: { ack: SubscriptionFilter } | { cause: 'local' | 'remote'; error?: Error }) => void; } +/** + * Per-tool result of compiling an `outputSchema` (SEP-2106). Stored on the + * response-cache substrate's stamp-keyed `name → validator` index so it + * inherits that substrate's invalidation lifecycle (`list_changed` evicts, + * a refetched `tools/list` re-derives, `resetForReconnect` clears) — no + * parallel map to keep in sync. + * + * @internal + */ +type OutputSchemaCompileResult = + | { ok: true; validator: JsonSchemaValidator } + | { ok: false; validator?: undefined; compileError: unknown }; + /** * An MCP client on top of a pluggable transport. * @@ -1711,22 +1724,29 @@ export class Client extends Protocol { } /** - * Compile a single tool's `outputSchema` (or `undefined` when absent / - * uncompilable) — the caller-supplied-definition path of - * {@linkcode callTool} so an explicit `options.toolDefinition` is the - * source for BOTH mirroring AND output validation. Also passed as the - * compile callback to {@linkcode ClientResponseCache.outputValidator} so - * the cache class stays free of any validator-provider dependency. + * Compile a single tool's `outputSchema`. Passed as the compile callback to + * {@linkcode ClientResponseCache.outputValidator} so the cache class stays + * free of any validator-provider dependency, and called directly for the + * `options.toolDefinition` path of {@linkcode callTool} (a one-off + * caller-supplied definition is compiled in isolation and never enters the + * cache, so it cannot poison the listed tool of the same name). + * + * Returns `undefined` when the tool has no `outputSchema`, or a + * discriminated `{ok}` result otherwise. SEP-2106: ANY throw from the + * validator engine — unsupported `$schema` dialect, invalid `pattern` + * regex, unresolvable `$ref`, or any other engine error — is captured as + * `{ok: false, compileError}` so one bad schema does not poison the rest + * of the listing; `callTool()` surfaces it as an `InvalidParams` error + * before the request. The `{ok}` discriminator (not + * `compileError !== undefined`) means a custom provider that does + * `throw undefined` is still treated as a captured failure. */ - private _compileOutputValidator(tool: Tool): JsonSchemaValidator | undefined { + private _compileOutputValidator(tool: Tool): OutputSchemaCompileResult | undefined { if (!tool.outputSchema) return undefined; try { - return this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType); + return { ok: true, validator: this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType) }; } catch (error) { - console.warn( - `[mcp-sdk] tool '${tool.name}': outputSchema failed to compile and will not be validated — ${error instanceof Error ? error.message : String(error)}` - ); - return undefined; + return { ok: false, compileError: error }; } } @@ -2169,9 +2189,13 @@ export class Client extends Protocol { * arguments: { weightKg: 70, heightM: 1.75 } * }); * - * // Machine-readable output for the client application - * if (result.structuredContent) { - * console.log(result.structuredContent); // e.g. { bmi: 22.86 } + * // Machine-readable output for the client application. SEP-2106: structuredContent is + * // `unknown` (any JSON value). Check for presence with `!== undefined` and narrow before use. + * if (result.structuredContent !== undefined) { + * const sc: unknown = result.structuredContent; // e.g. { bmi: 22.86 } + * if (typeof sc === 'object' && sc !== null && 'bmi' in sc) { + * console.log(sc.bmi); + * } * } * ``` */ @@ -2221,6 +2245,28 @@ export class Client extends Protocol { return Object.keys(paramHeaders).length === 0 ? options : { ...options, headers: { ...options?.headers, ...paramHeaders } }; }; + // SEP-2106: resolve the output validator BEFORE the request so a tool whose outputSchema + // fails to compile (unsupported `$schema` dialect / invalid pattern / unresolvable `$ref` / + // any engine error) is surfaced here, per-tool, without a wasted network round-trip and + // server-side handler execution. When the caller supplied `toolDefinition`, that definition + // is the source for BOTH the `Mcp-Param-*` mirroring above AND output validation — the two + // derived views must agree — and is compiled in isolation (never written to the cache). The + // cache read is guarded: a custom store whose `get()` rejects routes to `onerror` and + // degrades to skipping validation (same outcome as a cold cache). + let compiled = + options?.toolDefinition === undefined + ? await this._cache + .outputValidator(params.name, tool => this._compileOutputValidator(tool)) + .catch(error => void this._reportStoreError(error)) + : this._compileOutputValidator(options.toolDefinition); + const assertCompiled = (): void => { + if (compiled === undefined || compiled.ok) return; + const err = compiled.compileError; + const message = (err instanceof Error ? err.message : String(err)).slice(0, 200); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool '${params.name}' has an invalid outputSchema: ${message}`); + }; + assertCompiled(); + // The method-keyed request() path validates the era registry's plain // CallToolResult schema — with the result map aligned to the typed // map there is no wider union to narrow away (Q1-SD2 holds by @@ -2259,28 +2305,25 @@ export class Client extends Protocol { // `HEADER_MISMATCH` — the refetch failure is observable only // through `onerror`. await this.listTools(undefined, refreshOptions).catch(error_ => this._reportStoreError(error_)); + // Re-resolve the output validator against the freshly-fetched entry — the pre-flight + // `compiled` was resolved from the now-evicted cache and may be stale (different + // outputSchema) or absent (cold cache on the first attempt). The recovery path is only + // entered when `options.toolDefinition` is undefined, so the cache is the sole source. + // Re-run the same fail-fast compile-error check before issuing the retry. + compiled = await this._cache + .outputValidator(params.name, tool => this._compileOutputValidator(tool)) + .catch(error_ => void this._reportStoreError(error_)); + assertCompiled(); result = await this.request({ method: 'tools/call', params }, await buildSendOptions()); } - // Check if the tool has an outputSchema. When the caller supplied - // `toolDefinition`, that definition is the source for BOTH the - // `Mcp-Param-*` mirroring above AND the output validation here — the - // two derived views must agree. The cache read is guarded the same - // way as `evict()`/`set()` above: a custom store whose `get()` - // rejects AFTER the server has already executed the call must not - // surface as a `callTool()` rejection (a caller that retries on - // failure would re-execute a possibly side-effecting tool). Route to - // `onerror` and degrade to skipping validation — the same outcome as - // a cold cache. - const validator = - options?.toolDefinition === undefined - ? await this._cache - .outputValidator(params.name, tool => this._compileOutputValidator(tool)) - .catch(error => void this._reportStoreError(error)) - : this._compileOutputValidator(options.toolDefinition); + const validator = compiled !== undefined && compiled.ok ? compiled.validator : undefined; if (validator) { - // If tool has outputSchema, it MUST return structuredContent (unless it's an error) - if (!result.structuredContent && !result.isError) { + // If tool has outputSchema, it MUST return structuredContent (unless it's an error). + // SEP-2106: presence is `=== undefined`, not falsy — `null`/`0`/`false`/`""` are legal + // values and are validated below (so a falsy value against an object-typed schema still + // fails; this is not a guard weakening). + if (result.structuredContent === undefined && !result.isError) { throw new ProtocolError( ProtocolErrorCode.InvalidRequest, `Tool ${params.name} has an output schema but did not return structured content` @@ -2288,7 +2331,7 @@ export class Client extends Protocol { } // Only validate structured content if present (not when there's an error) - if (result.structuredContent) { + if (result.structuredContent !== undefined && !result.isError) { try { // Validate the structured content against the schema const validationResult = validator(result.structuredContent); diff --git a/packages/client/src/client/responseCache.ts b/packages/client/src/client/responseCache.ts index cc99221e7e..0fbf451cc2 100644 --- a/packages/client/src/client/responseCache.ts +++ b/packages/client/src/client/responseCache.ts @@ -612,9 +612,14 @@ export class ClientResponseCache { * `Client` passes its `_jsonSchemaValidator` wrapper) so this * class carries no validator-provider dependency. One tool's uncompilable * `outputSchema` (e.g. an invalid `pattern` regex or unresolvable `$ref`) - * must not poison every other tool's `callTool` — the callback returns - * `undefined` (and warns naming the offender) for the bad one and the - * index simply omits it. + * must not poison every other tool's `callTool` — the callback isolates + * that compile error per tool by returning a per-tool error variant which + * the index stores alongside the good ones, and `callTool` surfaces it as + * a typed `InvalidParams` only for that name. Because the error is held on + * this stamp-keyed substrate (not a parallel map), it inherits the + * substrate's invalidation lifecycle: a `list_changed` eviction drops it, + * a refetched `tools/list` re-derives it, and `resetForReconnect` clears + * the lot. */ async outputValidator(name: string, compile: (tool: Tool) => V | undefined): Promise { const entry = await this._probe('tools/list'); @@ -625,10 +630,8 @@ export class ClientResponseCache { if (this._toolOutputValidatorIndex?.stamp !== entry.stamp) { const byName = new Map(); for (const tool of (entry.value as ListToolsResult).tools) { - if (tool.outputSchema) { - const validator = compile(tool); - if (validator !== undefined) byName.set(tool.name, validator); - } + const compiled = compile(tool); + if (compiled !== undefined) byName.set(tool.name, compiled); } this._toolOutputValidatorIndex = { stamp: entry.stamp, byName }; } diff --git a/packages/client/test/client/jsonSchemaValidatorOverride.test.ts b/packages/client/test/client/jsonSchemaValidatorOverride.test.ts index 87ff1e9055..dc55776eda 100644 --- a/packages/client/test/client/jsonSchemaValidatorOverride.test.ts +++ b/packages/client/test/client/jsonSchemaValidatorOverride.test.ts @@ -114,6 +114,89 @@ describe('client JSON Schema validator overrides', () => { await serverTransport.close(); }); + describe('outputSchema compile-error lifecycle (substrate-held; no parallel map)', () => { + // SEP-2106 §invalid-outputSchema: a tool whose outputSchema fails to compile is + // surfaced as a typed InvalidParams BEFORE the request is sent. The compile error is + // held on the response-cache substrate's stamp-keyed `name → validator` index, so it + // inherits that substrate's invalidation lifecycle — a refetched `tools/list` re-derives + // it from scratch (no stale-entry bug when the server fixes the tool by removing the + // schema). The caller-supplied `toolDefinition` path is compiled in isolation and never + // touches the cache, so a one-off bad definition cannot poison the listed tool. + async function connectMutableToolsClient(getTools: () => unknown[]) { + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + serverTransport.onmessage = async message => { + if (!('method' in message) || !('id' in message)) return; + if (message.method === 'initialize') { + await serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: 'test-server', version: '1.0.0' } + } + }); + } else if (message.method === 'tools/list') { + await serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { tools: getTools() } + } satisfies JSONRPCMessage); + } else if (message.method === 'tools/call') { + await serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { content: [{ type: 'text', text: 'ok' }], structuredContent: { count: 1 } } + } satisfies JSONRPCMessage); + } + }; + await Promise.all([client.connect(clientTransport), serverTransport.start()]); + return { client, close: () => Promise.all([client.close(), clientTransport.close(), serverTransport.close()]) }; + } + + // An external `$ref` throws at compile time inside Ajv (MissingRefError — no fetch is + // attempted) and is captured per-tool by `_compileOutputValidator`. + const BAD_SCHEMA = { type: 'object', $ref: 'https://example.invalid/schema.json' } as const; + const GOOD_SCHEMA = { type: 'object', properties: { count: { type: 'number' } } } as const; + + test('re-advertising a tool WITHOUT the bad outputSchema clears the captured failure', async () => { + let tools: unknown[] = [{ name: 't', inputSchema: { type: 'object' }, outputSchema: BAD_SCHEMA }]; + const { client, close } = await connectMutableToolsClient(() => tools); + + await client.listTools(); + await expect(client.callTool({ name: 't' })).rejects.toThrow(/invalid outputSchema/); + + // Server fixes the tool by removing outputSchema entirely; refetched `tools/list` + // re-derives the index from scratch — no stale compile-error entry survives. + tools = [{ name: 't', inputSchema: { type: 'object' } }]; + await client.listTools(); + await expect(client.callTool({ name: 't' })).resolves.toMatchObject({ + content: [{ type: 'text', text: 'ok' }] + }); + + await close(); + }); + + test('a one-off `toolDefinition` with a bad outputSchema does not poison the listed tool', async () => { + const tools: unknown[] = [{ name: 't', inputSchema: { type: 'object' }, outputSchema: GOOD_SCHEMA }]; + const { client, close } = await connectMutableToolsClient(() => tools); + + await client.listTools(); + await expect( + client.callTool({ name: 't' }, { toolDefinition: { name: 't', inputSchema: { type: 'object' }, outputSchema: BAD_SCHEMA } }) + ).rejects.toThrow(/invalid outputSchema/); + + // Subsequent plain callTool of the same name (against the cached, valid listed + // schema) succeeds — the one-off definition never entered the cache. + await expect(client.callTool({ name: 't' })).resolves.toMatchObject({ + structuredContent: { count: 1 } + }); + + await close(); + }); + }); + test('fromJsonSchema uses an explicitly supplied custom validator', async () => { const validator = new RecordingValidator(); const schema: JsonSchemaType = { From c21d9918a55815e1daf071acaacd4eabb47dbdf2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 23 Jun 2026 15:52:18 +0000 Subject: [PATCH 4/6] =?UTF-8?q?feat(server):=20SEP-2106=20legacy=20interop?= =?UTF-8?q?=20=E2=80=94=20wrap=20non-object=20outputSchema/structuredConte?= =?UTF-8?q?nt=20in=20{result:=E2=80=A6}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool registered with a non-object output schema (z.array(...), z.string(), discriminated/anyOf compositions, …) now serves both protocol eras: - 2026-07-28 client: the natural schema and the natural structuredContent value are sent as-is. - ≤ 2025-11-25 client: the outputSchema is wrapped in {type:'object',properties:{result:},required:['result']} and the structuredContent is wrapped as {result: }, so the result satisfies the 2025 wire shape (object-only structuredContent / type:'object'-rooted outputSchema). Same-document $ref JSON Pointers inside the natural schema are rewritten to account for the new #/properties/result root (mirrors the C# SDK's TransformOutputSchemaForLegacyWire). A {type:'text', text: JSON.stringify(value)} block is auto-appended to content when the handler did not author one and structuredContent is a non-object value, so legacy clients that ignore structuredContent (or do not unwrap the envelope) still receive a rendering. The author opts out by returning any text block themselves. Object-shaped structured content is unchanged on both eras. structuredContent presence in validateToolOutput is checked with === undefined (0/false/''/null are legal SEP-2106 values and are validated against the schema). --- packages/server/src/server/mcp.ts | 141 +++++++++++++++++- .../server/test/server/mcp.compat.test.ts | 20 +++ 2 files changed, 157 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 5eab993c30..a2ed07263b 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -32,6 +32,7 @@ import { assertValidCacheHint, attachCacheHintFallback, isInputRequiredResult, + isModernProtocolVersion, normalizeRawShapeSchema, promptArgumentsFromStandardSchema, ProtocolError, @@ -82,6 +83,12 @@ export class McpServer { */ private _toolInputSchemaJson: { [name: string]: Record } = {}; + /** Whether the negotiated protocol revision is the modern (≥ 2026-07-28) era. */ + private _isModernEra(): boolean { + const v = this.server.getNegotiatedProtocolVersion(); + return v !== undefined && isModernProtocolVersion(v); + } + /** * The JSON-serialized `inputSchema` of a registered tool, or `undefined` * when no such tool is registered. Used by the HTTP entry's pre-dispatch @@ -193,7 +200,17 @@ export class McpServer { }; if (tool.outputSchema) { - toolDefinition.outputSchema = standardSchemaToJsonSchema(tool.outputSchema, 'output') as Tool['outputSchema']; + const json = standardSchemaToJsonSchema(tool.outputSchema, 'output'); + // SEP-2106 legacy interop: a non-object outputSchema root is + // 2026-only vocabulary. On the legacy era it is wrapped in a + // `{type:'object',properties:{result:},required:['result']}` + // envelope so 2025 clients keep seeing/calling the tool. The + // matching `structuredContent` wrap is applied in the tools/call + // handler — both follow the SAME per-tool `legacyWrapsOutput` + // decision computed at registration time, so the advertised schema + // and the result projection cannot diverge. + toolDefinition.outputSchema = + tool.legacyWrapsOutput && !this._isModernEra() ? wrapOutputSchemaForLegacy(json) : json; } return toolDefinition; @@ -214,7 +231,7 @@ export class McpServer { const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); const result = await this.executeToolHandler(tool, args, ctx); await this.validateToolOutput(tool, result, request.params.name); - return result; + return projectStructuredContentForLegacy(result, tool.legacyWrapsOutput, this._isModernEra()); } catch (error) { if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { throw error; // Return the error to the caller without wrapping in CallToolResult @@ -288,7 +305,11 @@ export class McpServer { return; } - if (!result.structuredContent) { + // SEP-2106: `structuredContent` may legally be any JSON value including `null`, `0`, + // `false`, `""`. The presence check is therefore `=== undefined` (not falsy); when present, + // the value is ALWAYS validated against the output schema — a falsy value against an + // object-typed schema fails validation, so this is not a guard weakening. + if (result.structuredContent === undefined) { throw new ProtocolError( ProtocolErrorCode.InvalidParams, `Output validation error: Tool ${toolName} has an output schema but no structured content was provided` @@ -823,6 +844,7 @@ export class McpServer { description, inputSchema, outputSchema, + legacyWrapsOutput: computeLegacyWrapsOutput(outputSchema), annotations, execution, _meta, @@ -874,7 +896,10 @@ export class McpServer { registeredTool.executor = createToolExecutor(registeredTool.inputSchema, currentHandler); } - if (updates.outputSchema !== undefined) registeredTool.outputSchema = updates.outputSchema; + if (updates.outputSchema !== undefined) { + registeredTool.outputSchema = updates.outputSchema; + registeredTool.legacyWrapsOutput = computeLegacyWrapsOutput(updates.outputSchema); + } if (updates.annotations !== undefined) registeredTool.annotations = updates.annotations; if (updates._meta !== undefined) registeredTool._meta = updates._meta; if (updates.enabled !== undefined) registeredTool.enabled = updates.enabled; @@ -1223,6 +1248,14 @@ export type RegisteredTool = { description?: string; inputSchema?: StandardSchemaWithJSON; outputSchema?: StandardSchemaWithJSON; + /** + * @hidden + * Per-tool SEP-2106 legacy-wrap decision: whether the converted `outputSchema` has a non-object + * root and is therefore advertised wrapped in `{result:…}` toward 2025-era clients. Computed + * once at registration (and on `update({outputSchema})`) so `tools/list` and `tools/call` apply + * the SAME decision — see {@link projectStructuredContentForLegacy}. + */ + legacyWrapsOutput: boolean; annotations?: ToolAnnotations; execution?: ToolExecution; _meta?: Record; @@ -1272,6 +1305,106 @@ const EMPTY_OBJECT_JSON_SCHEMA = { properties: {} }; +/** + * True when a converted output JSON Schema's root is not `type: 'object'` (SEP-2106 vocabulary) — + * either an explicit non-object `type` or a typeless root such as `{anyOf:[…]}`. The 2025 wire + * shape requires `type:'object'` at the root of `outputSchema`, so anything else is wrapped in a + * `{result: …}` envelope on the legacy projection. `standardSchemaToJsonSchema(…, 'output')` + * already stamps `type:'object'` onto provably object-shaped typeless roots, so those still pass. + */ +function isNonObjectJsonSchemaRoot(json: Record): boolean { + return json.type !== 'object'; +} + +/** + * Per-tool SEP-2106 legacy-wrap decision: convert the registered `outputSchema` to JSON Schema and + * check whether the root is non-object. Computed at registration time and stored on + * {@link RegisteredTool.legacyWrapsOutput} so `tools/list` and `tools/call` apply the SAME decision + * (the wrap predicate must follow the advertised SCHEMA, not the runtime VALUE shape — a tool whose + * schema is `z.union([z.object(...), z.string()])` advertises wrapped on the legacy era, so an + * object-valued result must wrap too). A conversion failure yields `false` so the failure surfaces + * where it always has (`tools/list`). + */ +function computeLegacyWrapsOutput(outputSchema: StandardSchemaWithJSON | undefined): boolean { + if (outputSchema === undefined) return false; + try { + return isNonObjectJsonSchemaRoot(standardSchemaToJsonSchema(outputSchema, 'output')); + } catch { + return false; + } +} + +/** + * Keys whose values are instance-data positions in a JSON Schema (not subschemas). A `{$ref:…}` + * appearing inside one is a literal value, not a JSON Pointer to rewrite. + */ +const REF_REWRITE_DATA_POSITION_KEYS: ReadonlySet = new Set(['const', 'enum', 'default', 'examples']); + +/** + * SEP-2106 legacy interop: wrap a non-object output schema in + * `{type:'object',properties:{result:},required:['result']}`. + * + * Same-document `$ref` / `$dynamicRef` JSON Pointers inside the natural schema (e.g. zod produces + * `#/properties/foo` for de-duplicated/recursive types) are rewritten to account for the new + * `#/properties/result` root: bare `#` → `#/properties/result`, `#/…` → `#/properties/result/…`. + * Cross-document refs (anything not starting with `#`) are left untouched. Data positions + * (`const`/`enum`/`default`/`examples`) are NOT descended into — their values are instance data, + * not subschemas. Mirrors the C# SDK's `TransformOutputSchemaForLegacyWire` (and goes beyond it on + * `$dynamicRef` and the data-position skip). + */ +function wrapOutputSchemaForLegacy(natural: Record): Tool['outputSchema'] { + const rewriteRefs = (node: unknown): unknown => { + if (Array.isArray(node)) return node.map(item => rewriteRefs(item)); + if (node === null || typeof node !== 'object') return node; + const out: Record = {}; + for (const [k, v] of Object.entries(node)) { + if ((k === '$ref' || k === '$dynamicRef') && typeof v === 'string') { + out[k] = v === '#' ? '#/properties/result' : v.startsWith('#/') ? `#/properties/result${v.slice(1)}` : v; + } else if (REF_REWRITE_DATA_POSITION_KEYS.has(k)) { + out[k] = v; + } else { + out[k] = rewriteRefs(v); + } + } + return out; + }; + return { type: 'object', properties: { result: rewriteRefs(natural) }, required: ['result'] }; +} + +/** + * SEP-2106 result-side projection. Two independent decisions: + * + * - **TextContent auto-append** (SEP-2106 §4.3 backward-compatibility MUST, applies on EVERY era): + * when the handler returned a non-object `structuredContent` value (array/primitive/`null`) and + * no `type:'text'` content of its own, append `{type:'text', text: JSON.stringify(value)}` so + * consumers that read only `content` still receive a rendering. The author opts out by returning + * any `text` block themselves. This is value-shape-based. + * + * - **`{result:…}` wrap** (legacy ≤ 2025-11-25 era only): follows the SAME per-tool + * `legacyWrapsOutput` decision `tools/list` used to advertise the schema (computed from the + * schema's root, not from this call's value). When set, ALWAYS wrap as `{result: }` on the + * legacy era — including object-valued results — so the result matches the wrapped schema. When + * not set (object-root schema, or no `outputSchema`), never wrap. + */ +function projectStructuredContentForLegacy( + result: CallToolResult | InputRequiredResult, + legacyWrapsOutput: boolean, + isModernEra: boolean +): CallToolResult | InputRequiredResult { + if (isInputRequiredResult(result)) return result; + const sc = result.structuredContent; + if (sc === undefined) return result; + const isNonObjectValue = typeof sc !== 'object' || sc === null || Array.isArray(sc); + const wrap = !isModernEra && legacyWrapsOutput; + if (!isNonObjectValue && !wrap) return result; + const hasTextContent = result.content?.some(c => c.type === 'text') ?? false; + const content = + isNonObjectValue && !hasTextContent + ? [...(result.content ?? []), { type: 'text' as const, text: JSON.stringify(sc) }] + : result.content; + return { ...result, content, structuredContent: wrap ? { result: sc } : sc }; +} + /** * Additional, optional information for annotating a resource. */ diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index 322b615353..9adb866ff9 100644 --- a/packages/server/test/server/mcp.compat.test.ts +++ b/packages/server/test/server/mcp.compat.test.ts @@ -127,3 +127,23 @@ describe('InferRawShape', () => { expectTypeOf().toEqualTypeOf<{ a: string; b?: string | undefined }>(); }); }); + +describe('SEP-2106: registerTool with non-object outputSchema (type-level)', () => { + it('accepts z.array(z.number()) as outputSchema and a number[] structuredContent compiles', () => { + const server = new McpServer({ name: 's', version: '1' }); + server.registerTool('arr', { inputSchema: z.object({ n: z.number() }), outputSchema: z.array(z.number()) }, async ({ n }) => ({ + content: [], + structuredContent: [n, n + 1] satisfies number[] + })); + // NOTE (SEP-2106 PR-B verification item): the OutputArgs generic on registerTool is + // captured but does NOT currently flow into the callback's return type — ToolCallback's + // SendResultT is `CallToolResult | InputRequiredResult` (structuredContent: unknown), so + // a wrong-typed structuredContent ALSO compiles. Runtime validation (validateToolOutput) + // is the guard. Tightening the generic is out of this commit's scope. + server.registerTool('arr-loose', { outputSchema: z.array(z.number()) }, async () => ({ + content: [], + structuredContent: 'not-an-array' // compiles: structuredContent is `unknown` + })); + expectTypeOf().toMatchTypeOf>>>(); + }); +}); From 37152ad72742cffdbc96505191fd7f4f2afbf418 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 23 Jun 2026 15:57:35 +0000 Subject: [PATCH 5/6] test(conformance,e2e): SEP-2106 fixtures + e2e rows; burn json-schema expected-failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conformance: - everythingClient: register the json-schema-ref-no-deref scenario (a plain connect → listTools → close — output schemas compile lazily on the first callTool and the underlying engine never fetches external refs). - everythingServer: rewrite the json_schema_2020_12_tool inputSchema as a hand-authored JSON Schema (via fromJsonSchema) so the SEP-1613 keywords the scenario checks ($schema/$defs/$anchor/allOf/anyOf/if-then-else/ additionalProperties) survive tools/list verbatim. - expected-failures: burn json-schema-ref-no-deref (both legs) and json-schema-2020-12 (2026 leg). e2e: new scenarios/jsonschema.test.ts + 12 requirements.ts rows covering the SEP-1613/SEP-2106 validator posture — same-document $ref resolves; unsupported $schema dialect surfaces as a clear InvalidParams; one uncompilable schema (engine MissingRefError) is isolated per-tool; non-object outputSchema/structuredContent round-trip on 2026-07-28; 2020-12 prefixItems is enforced by default; falsy structuredContent is treated as present; the auto TextContent fallback fires (and the author opt-out suppresses it); and on the 2025 era a non-object output schema / structured content is wrapped in {result:…} with same-document $ref pointers rewritten. clientGuide.examples.ts: structuredContent narrowing example. --- examples/guides/clientGuide.examples.ts | 10 +- .../expected-failures.2026-07-28.yaml | 11 +- test/conformance/expected-failures.yaml | 2 - test/conformance/src/everythingClient.ts | 34 ++ test/conformance/src/everythingServer.ts | 48 +- test/e2e/requirements.ts | 115 ++++ test/e2e/scenarios/jsonschema.test.ts | 548 ++++++++++++++++++ 7 files changed, 744 insertions(+), 24 deletions(-) create mode 100644 test/e2e/scenarios/jsonschema.test.ts diff --git a/examples/guides/clientGuide.examples.ts b/examples/guides/clientGuide.examples.ts index 68ef6015a3..e16bdf7a85 100644 --- a/examples/guides/clientGuide.examples.ts +++ b/examples/guides/clientGuide.examples.ts @@ -218,9 +218,13 @@ async function callTool_structuredOutput(client: Client) { arguments: { weightKg: 70, heightM: 1.75 } }); - // Machine-readable output for the client application - if (result.structuredContent) { - console.log(result.structuredContent); // e.g. { bmi: 22.86 } + // Machine-readable output for the client application. SEP-2106: structuredContent is + // `unknown` (any JSON value). Check for presence with `!== undefined` and narrow before use. + if (result.structuredContent !== undefined) { + const sc: unknown = result.structuredContent; // e.g. { bmi: 22.86 } + if (typeof sc === 'object' && sc !== null && 'bmi' in sc) { + console.log(sc.bmi); + } } //#endregion callTool_structuredOutput } diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml index 20b9f30c2b..435a1d0633 100644 --- a/test/conformance/expected-failures.2026-07-28.yaml +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -52,8 +52,6 @@ client: - auth/scope-retry-limit # --- Same gaps as the 2025 baseline (fail identically when forced to 2026-07-28) --- - # SEP-2106 (JSON Schema $ref handling): no fixture handler for the scenario yet. - - json-schema-ref-no-deref # SEP-2468 (authorization response iss parameter): not implemented in the client. - auth/iss-supported - auth/iss-not-advertised @@ -66,10 +64,7 @@ client: # when PRM authorization_servers changes. - auth/authorization-server-migration -server: +server: [] # --- Carried-forward scenarios (also run by the 2025 legs) --- - # Pre-existing fixture/baseline bug: the fixture tool's schema is a plain - # Zod object with none of the JSON Schema 2020-12 keywords the scenario - # checks; it fails identically at 2025 in `--suite all` (not a 2026-path - # regression). - - json-schema-2020-12 + # (empty: json-schema-2020-12 burned by SEP-2106 fixture; sep-2164-resource-not-found + # burned by the spec#2907 error-code renumber + alpha.5 referee.) diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index ba9ab0d455..ae5ce4a4c8 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -19,8 +19,6 @@ client: # --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) --- - # SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs. - - json-schema-ref-no-deref # SEP-2468 (authorization response iss parameter): not implemented in the client. - auth/iss-supported - auth/iss-not-advertised diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index 92e70f9f1a..a418b878eb 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -686,6 +686,40 @@ async function runSSERetryClient(serverUrl: string): Promise { registerScenario('sse-retry', runSSERetryClient); +// ============================================================================ +// JSON Schema $ref dereference scenario (SEP-2106) +// ============================================================================ + +/** + * The scenario serves a tool whose outputSchema carries a network `$ref`; the + * conformance check passes when the client lists tools without dereferencing + * (fetching) that URL. The SDK never dereferences network refs — output + * schemas are compiled lazily on the first `callTool()` against the cached + * `tools/list` entry, and the underlying engine (Ajv / cfworker) does not + * fetch external refs (Ajv throws `MissingRefError`, captured per-tool) — so + * a plain connect → listTools → close is sufficient: `listTools()` returns + * normally and the canary URL is never fetched. + */ +async function runJsonSchemaRefNoDerefClient(serverUrl: string): Promise { + const client = new Client({ name: 'json-schema-ref-no-deref-client', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + const tools = await client.listTools(); + logger.debug( + 'Available tools:', + tools.tools.map(t => t.name) + ); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('json-schema-ref-no-deref', runJsonSchemaRefNoDerefClient); + // ============================================================================ // Main entry point // ============================================================================ diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 256fb43ff9..7e11a83e67 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -665,22 +665,48 @@ function createMcpServer() { } ); - // SEP-1613: JSON Schema 2020-12 conformance test tool + // SEP-1613 / SEP-2106: JSON Schema 2020-12 conformance test tool. + // The scenario verifies that $schema/$defs/additionalProperties (SEP-1613) + // and the broader 2020-12 vocabulary — $anchor, allOf/anyOf, if/then/else — + // (SEP-2106) survive tools/list verbatim. The schema is hand-authored JSON + // (via fromJsonSchema) so the keywords are advertised exactly as written; + // a Zod object would not emit them. mcpServer.registerTool( 'json_schema_2020_12_tool', { - description: 'Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)', - inputSchema: z.object({ - name: z.string().optional(), - address: z - .object({ - street: z.string().optional(), - city: z.string().optional() - }) - .optional() + description: 'Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613, SEP-2106)', + inputSchema: fromJsonSchema({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + $defs: { + address: { + $anchor: 'addressDef', + type: 'object', + properties: { + street: { type: 'string' }, + city: { type: 'string' } + } + } + }, + properties: { + name: { type: 'string' }, + address: { $ref: '#/$defs/address' }, + contactMethod: { type: 'string', enum: ['phone', 'email'] }, + phone: { type: 'string' }, + email: { type: 'string' } + }, + allOf: [{ anyOf: [{ required: ['phone'] }, { required: ['email'] }] }], + if: { + properties: { contactMethod: { const: 'phone' } }, + required: ['contactMethod'] + }, + // eslint-disable-next-line unicorn/no-thenable -- `then` is a JSON Schema 2020-12 keyword + then: { required: ['phone'] }, + else: { required: ['email'] }, + additionalProperties: false }) }, - async (args: { name?: string; address?: { street?: string; city?: string } }): Promise => { + async (args): Promise => { return { content: [ { diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 265d90210f..7d1c2f77ac 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -505,6 +505,121 @@ export const REQUIREMENTS: Record = { source: 'sdk', behavior: 'Server-side output schema validation is skipped when the tool returns an isError result.' }, + + // Tools: JSON Schema 2020-12 validator posture (SEP-1613 / SEP-2106) + + 'client:jsonschema:same-document-ref-ok': { + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#output-schema', + behavior: + 'A tool whose advertised outputSchema uses same-document $ref ("#/$defs/…" or "#anchor") compiles on the client and validates structuredContent against the referenced subschema.' + }, + 'client:jsonschema:unsupported-dialect-graceful': { + source: 'sdk', + behavior: + 'A tool whose advertised outputSchema declares a $schema dialect URI the built-in validator does not recognise is refused gracefully on the client: callTool throws InvalidParams with a clear "unsupported dialect … 2020-12 only" message instead of having the underlying engine fail opaquely.' + }, + 'client:jsonschema:bad-schema-isolates-tool': { + source: 'sdk', + behavior: + 'One bad outputSchema in a tools/list response (a schema the validator engine refuses to compile — e.g. an unresolvable external $ref) does not poison the listing: tools/list resolves with every tool present, callTool on the bad tool throws InvalidParams, and callTool on the other tools succeeds.' + }, + 'client:jsonschema:non-object-output': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#output-schema', + behavior: + 'A tool whose advertised outputSchema has a non-object root (e.g. type:"array") is accepted by the client validator on the 2026-07-28 era: structuredContent matching that root validates and is returned typed unknown.', + note: 'Restricted to the entryModern arm because the 2025-era wire codec keeps outputSchema/structuredContent at their type:"object" / Record shapes (byte-identity), so a non-object root only round-trips natively on the 2026-07-28 path.' + }, + 'client:jsonschema:2020-12:prefixItems': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#output-schema', + behavior: + 'The default client validator enforces JSON Schema 2020-12 vocabulary: a tool whose advertised outputSchema uses prefixItems rejects structuredContent that violates the per-index item schemas (a draft-07 engine with strict:false would silently ignore prefixItems and accept).', + note: 'Restricted to the entryModern arm because the array-typed outputSchema/structuredContent only round-trip natively on the 2026-07-28 wire codec.' + }, + 'client:jsonschema:dialect:default-is-2020-12': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#output-schema', + behavior: + 'A tool whose advertised outputSchema declares no $schema is validated by the client with the 2020-12 engine (the default): a 2020-12-only keyword (prefixItems) in the schema is enforced, so structuredContent violating it causes callTool to throw InvalidParams.', + note: 'Restricted to the entryModern arm so the schema (carrying prefixItems) round-trips through the 2026-07-28 wire codec verbatim.' + }, + 'client:jsonschema:falsy-structured-content-validated': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#structured-content', + behavior: + 'A falsy structuredContent value (0, false, "", null) is treated as present by the client and validated against the cached outputSchema — the presence check is `=== undefined`, not falsy, so a tool returning structuredContent: 0 against outputSchema {type:"integer"} resolves with the value rather than throwing "did not return structured content".', + note: 'Restricted to the entryModern arm because primitive structuredContent only round-trips natively on the 2026-07-28 wire codec.' + }, + 'server:jsonschema:array-structured-content-textfallback': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#structured-content', + behavior: + 'A McpServer tool whose handler returns array-typed structuredContent and no text content has a {type:"text", text: JSON.stringify(structuredContent)} block auto-appended (the SEP-2106 backward-compatibility fallback) so legacy-style consumers still receive a rendering. An author-supplied text block suppresses the auto-append.', + note: 'Runs on the entryModern arm so the array structuredContent round-trips natively.' + }, + 'server:jsonschema:primitive-structured-content': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#structured-content', + behavior: + 'A McpServer tool whose handler returns primitive (string / number / boolean / null) structuredContent round-trips on the 2026-07-28 era: the value reaches the client as typed unknown and the auto TextContent fallback carries its JSON serialisation.', + note: 'Runs on the entryModern arm so a non-object structuredContent round-trips natively.' + }, + '2025:jsonschema:non-object-output-wrapped': { + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + source: 'sdk', + behavior: + 'On a 2025-era listing, a McpServer tool registered with a non-object-root outputSchema has the outputSchema wrapped in {type:"object",properties:{result:},required:["result"]} (the SEP-2106 legacy interop envelope): the tool stays listed, the schema is valid 2025 wire data, and a 2025 client can compile/validate against the wrapped shape.', + note: 'Bounded to the 2025-11-25 axis on the entryStateless arm: a statement about what 2025-era clients see when served by a SEP-2106-aware server.' + }, + '2025:jsonschema:non-object-structured-content-wrapped': { + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + source: 'sdk', + behavior: + 'On a 2025-era tools/call, a McpServer tool whose handler returns non-object structuredContent (array/primitive/null) has the auto-TextContent fallback injected and the structuredContent wrapped as {result:}: the result satisfies both the 2025 wire shape (object-only) and the wrapped outputSchema advertised in tools/list.', + note: 'Bounded to the 2025-11-25 axis on the entryStateless arm. The result-side mirror of the legacy outputSchema wrap.' + }, + '2025:jsonschema:ref-rewrite-on-wrap': { + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + source: 'sdk', + behavior: + 'On a 2025-era listing, a non-object outputSchema with same-document $ref JSON Pointers ("#", "#/…") wrapped under #/properties/result has every such $ref rewritten to keep resolving: the wrapped schema compiles on the client and validates the wrapped {result:…} structuredContent.', + note: 'Bounded to the 2025-11-25 axis on the entryStateless arm. Mirrors the C# SDK TransformOutputSchemaForLegacyWire.' + }, + '2025:jsonschema:ref-rewrite-scope': { + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + source: 'sdk', + behavior: + 'The legacy-wrap $ref rewrite applies to $ref AND $dynamicRef in subschema positions, but NOT to instance-data positions (const/enum/default/examples) — a {$ref:…} appearing inside a data position is a literal value and is left untouched.', + note: 'Bounded to the 2025-11-25 axis on the entryStateless arm. Goes beyond the C# RewriteRefPointers on both points.' + }, + '2025:jsonschema:wrap-follows-schema-not-value': { + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + source: 'sdk', + behavior: + 'On the 2025 era, a McpServer tool whose outputSchema has a non-object root (e.g. z.union([z.object(...), z.string()]) → typeless {anyOf:[…]}) wraps EVERY structuredContent value as {result:} — including object-valued results — so the result always satisfies the wrapped outputSchema advertised in tools/list. The wrap predicate follows the per-tool schema decision, not the runtime value shape.', + note: 'Bounded to the 2025-11-25 axis on the entryStateless arm. The schema-side mirror is 2025:jsonschema:non-object-output-wrapped.' + }, + 'server:jsonschema:union-output-natural': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'sdk', + behavior: + 'On the 2026 era, a McpServer tool whose outputSchema is z.union([z.object(...), z.string()]) advertises the natural typeless {anyOf:[…]} root and returns structuredContent unwrapped on both branches (object and string); the era-agnostic auto-TextContent fallback still fires for the non-object branch.', + note: 'Runs on the entryModern arm so the typeless-root outputSchema and primitive structuredContent round-trip natively.' + }, + 'mcpserver:tool:duplicate-name': { source: 'sdk', behavior: 'Registering a tool with a name already in use is rejected at registration time.' diff --git a/test/e2e/scenarios/jsonschema.test.ts b/test/e2e/scenarios/jsonschema.test.ts new file mode 100644 index 0000000000..9ff5fefd6f --- /dev/null +++ b/test/e2e/scenarios/jsonschema.test.ts @@ -0,0 +1,548 @@ +/** + * Self-contained test bodies for the JSON Schema 2020-12 validator posture + * (SEP-1613 dialect, SEP-2106 non-object roots + legacy `{result:…}` wrap). + * + * Each export is a {@link verifies} body: it builds its own server (via a + * factory), builds its own client, wires them with {@link wire}, and asserts. + * There are no shared fixture imports; helpers local to multiple bodies live at + * the top of this file. + * + * The era-spanning bodies use `type:'object'`-rooted output schemas (so the + * 2025-era wire codec — which keeps `outputSchema`/`structuredContent` at their + * object/Record shapes for byte-identity — round-trips them on every arm). The + * non-object-root bodies are restricted to the createMcpHandler entry arms in + * `requirements.ts` because only the 2026-07-28 wire codec carries that + * vocabulary natively. + */ + +import { Client } from '@modelcontextprotocol/client'; +import type { Tool } from '@modelcontextprotocol/server'; +import { fromJsonSchema, McpServer, ProtocolError, ProtocolErrorCode, Server } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import { wire } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +/** Plain client with no extra capabilities declared. */ +const newClient = () => new Client({ name: 'c', version: '0' }); + +/** Object-root output schema with a same-document `$ref` into `$defs`. */ +const SAME_DOCUMENT_REF_OUTPUT = { + type: 'object' as const, + properties: { point: { $ref: '#/$defs/Point' } }, + required: ['point'], + $defs: { + Point: { + type: 'object', + properties: { x: { type: 'number' }, y: { type: 'number' } }, + required: ['x', 'y'] + } + } +}; + +/** + * Object-root output schema with an external (network) `$ref`. The SDK does not pre-screen + * `$ref` (the spec MUST-NOT is "do not dereference", not "reject") — the underlying Ajv engine + * does not fetch external refs and throws a `MissingRefError` at compile time, which the client + * captures per-tool and surfaces as `InvalidParams`. + */ +const NETWORK_REF_OUTPUT = { + type: 'object' as const, + properties: { point: { $ref: 'https://schemas.example.invalid/point.json' } }, + required: ['point'] +}; + +/** Object-root output schema declaring a `$schema` dialect URI no built-in provider recognises. */ +const UNKNOWN_DIALECT_OUTPUT = { + $schema: 'https://example.invalid/json-schema/v99/schema', + type: 'object' as const, + properties: { value: { type: 'number' } }, + required: ['value'] +}; + +/** + * Low-level Server factory advertising one tool per fixture output schema. + * The low-level Server applies no server-side output validation, so the + * client-side validator behavior under test is the only check in the path. + */ +function refSchemaServer(): Server { + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler('tools/list', () => ({ + tools: [ + { + name: 'network-ref', + inputSchema: { type: 'object' }, + outputSchema: NETWORK_REF_OUTPUT + }, + { + name: 'local-ref', + inputSchema: { type: 'object' }, + outputSchema: SAME_DOCUMENT_REF_OUTPUT + }, + { + name: 'unknown-dialect', + inputSchema: { type: 'object' }, + outputSchema: UNKNOWN_DIALECT_OUTPUT + } + ] + })); + s.setRequestHandler('tools/call', req => { + switch (req.params.name) { + case 'network-ref': + case 'local-ref': { + return { structuredContent: { point: { x: 1, y: 2 } }, content: [] }; + } + case 'unknown-dialect': { + return { structuredContent: { value: 7 }, content: [] }; + } + default: { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `unknown tool ${req.params.name}`); + } + } + }); + return s; +} + +verifies('client:jsonschema:same-document-ref-ok', async ({ transport }: TestArgs) => { + const client = newClient(); + await using _ = await wire(transport, refSchemaServer, client); + + const { tools } = await client.listTools(); + const tool = tools.find(t => t.name === 'local-ref'); + expect(tool?.outputSchema).toMatchObject({ $defs: { Point: { type: 'object' } } }); + + const r = await client.callTool({ name: 'local-ref', arguments: {} }); + expect(r.isError).toBeFalsy(); + expect(r.structuredContent).toEqual({ point: { x: 1, y: 2 } }); +}); + +verifies('client:jsonschema:unsupported-dialect-graceful', async ({ transport }: TestArgs) => { + const client = newClient(); + await using _ = await wire(transport, refSchemaServer, client); + + const { tools } = await client.listTools(); + expect(tools.find(t => t.name === 'unknown-dialect')?.outputSchema).toMatchObject({ + $schema: 'https://example.invalid/json-schema/v99/schema' + }); + + const call = client.callTool({ name: 'unknown-dialect', arguments: {} }); + await expect(call).rejects.toBeInstanceOf(ProtocolError); + const err = await call.catch(error => error as ProtocolError); + expect(err.code).toBe(ProtocolErrorCode.InvalidParams); + expect(err.message).toMatch(/invalid outputSchema.*unsupported dialect/i); +}); + +verifies('client:jsonschema:bad-schema-isolates-tool', async ({ transport }: TestArgs) => { + const client = newClient(); + await using _ = await wire(transport, refSchemaServer, client); + + // The listing carries every tool, including the one whose schema the + // validator engine refuses to compile (external `$ref` → MissingRefError). + const { tools } = await client.listTools(); + expect(tools.map(t => t.name).toSorted()).toEqual(['local-ref', 'network-ref', 'unknown-dialect']); + + // The good tool is callable and validates. + const ok = await client.callTool({ name: 'local-ref', arguments: {} }); + expect(ok.isError).toBeFalsy(); + expect(ok.structuredContent).toEqual({ point: { x: 1, y: 2 } }); + + // The bad tool surfaces its compile failure lazily, per-tool. + const bad = client.callTool({ name: 'network-ref', arguments: {} }); + await expect(bad).rejects.toBeInstanceOf(ProtocolError); + const err = await bad.catch(error => error as ProtocolError); + expect(err.code).toBe(ProtocolErrorCode.InvalidParams); + expect(err.message).toMatch(/invalid outputSchema/i); +}); + +verifies('client:jsonschema:non-object-output', async ({ transport }: TestArgs) => { + // Low-level server with a non-object-root output schema. Only meaningful on + // the 2026-07-28 wire codec (entryModern arm), where outputSchema is a + // loose object and structuredContent is `unknown`. + const makeServer = (): Server => { + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler('tools/list', () => ({ + tools: [ + { + name: 'array-out', + inputSchema: { type: 'object' }, + outputSchema: { type: 'array', items: { type: 'number' } } + } + ] + })); + s.setRequestHandler('tools/call', () => ({ structuredContent: [1, 2, 3], content: [] })); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + const { tools } = await client.listTools(); + expect(tools.find(t => t.name === 'array-out')?.outputSchema).toMatchObject({ type: 'array' }); + + const r = await client.callTool({ name: 'array-out', arguments: {} }); + expect(r.isError).toBeFalsy(); + // SEP-2106: structuredContent is typed `unknown`; narrow at the call site. + expect(r.structuredContent).toEqual([1, 2, 3]); + expect(Array.isArray(r.structuredContent)).toBe(true); +}); + +verifies('client:jsonschema:2020-12:prefixItems', async ({ transport }: TestArgs) => { + // Low-level server advertising a 2020-12-only `prefixItems` outputSchema and + // returning structuredContent in the WRONG positional order. Ajv2020 + // enforces prefixItems → validation fails; a draft-07 Ajv with strict:false + // would ignore the keyword and accept. This pins the SEP-1613 default. + const makeServer = (): Server => { + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler('tools/list', () => ({ + tools: [ + { + name: 'tuple-out', + inputSchema: { type: 'object' }, + outputSchema: { + type: 'array', + prefixItems: [{ type: 'number' }, { type: 'string' }] + } + } + ] + })); + s.setRequestHandler('tools/call', () => ({ structuredContent: ['x', 1], content: [] })); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + await client.listTools(); + const call = client.callTool({ name: 'tuple-out', arguments: {} }); + await expect(call).rejects.toBeInstanceOf(ProtocolError); + const err = await call.catch(error => error as ProtocolError); + expect(err.code).toBe(ProtocolErrorCode.InvalidParams); + expect(err.message).toMatch(/output schema/i); +}); + +/** + * Low-level Server advertising a `prefixItems` outputSchema with no `$schema` + * stamp. The handler returns structuredContent that violates `prefixItems` + * (positions swapped). With the 2020-12 default, `prefixItems` is enforced and + * validation fails. + */ +function dialectServer(): Server { + const out: Tool['outputSchema'] = { + type: 'object', + properties: { v: { type: 'array', prefixItems: [{ type: 'number' }, { type: 'string' }] } }, + required: ['v'] + }; + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler('tools/list', () => ({ + tools: [{ name: 'no-stamp', inputSchema: { type: 'object' }, outputSchema: out }] + })); + s.setRequestHandler('tools/call', () => ({ structuredContent: { v: ['x', 1] }, content: [] })); + return s; +} + +verifies('client:jsonschema:dialect:default-is-2020-12', async ({ transport }: TestArgs) => { + const client = newClient(); + await using _ = await wire(transport, dialectServer, client); + + await client.listTools(); + // No `$schema` → 2020-12 default → `prefixItems` enforced → {v:['x',1]} invalid. + const call = client.callTool({ name: 'no-stamp', arguments: {} }); + await expect(call).rejects.toBeInstanceOf(ProtocolError); + const err = await call.catch(error => error as ProtocolError); + expect(err.code).toBe(ProtocolErrorCode.InvalidParams); + expect(err.message).toMatch(/output schema/i); +}); + +verifies('client:jsonschema:falsy-structured-content-validated', async ({ transport }: TestArgs) => { + // Low-level server with `outputSchema:{type:'integer'}` returning + // `structuredContent: 0`. Pins the SEP-2106 §4.3 `=== undefined` presence + // check on the client: a falsy value is treated as PRESENT and validated, + // not as missing. + const makeServer = (): Server => { + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler('tools/list', () => ({ + tools: [ + { + name: 'zero', + inputSchema: { type: 'object' }, + outputSchema: { type: 'integer' } + } + ] + })); + s.setRequestHandler('tools/call', () => ({ structuredContent: 0, content: [] })); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + await client.listTools(); + const r = await client.callTool({ name: 'zero', arguments: {} }); + expect(r.isError).toBeFalsy(); + expect(r.structuredContent).toBe(0); + expect(r.structuredContent === 0).toBe(true); +}); + +verifies('server:jsonschema:array-structured-content-textfallback', async ({ transport }: TestArgs) => { + const makeServer = (): McpServer => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool( + 'list-numbers', + { + inputSchema: z.object({}), + outputSchema: fromJsonSchema({ type: 'array', items: { type: 'number' } }) + }, + () => ({ structuredContent: [1, 2, 3], content: [] }) + ); + s.registerTool( + 'list-authored', + { + inputSchema: z.object({}), + outputSchema: fromJsonSchema({ type: 'array', items: { type: 'number' } }) + }, + () => ({ structuredContent: [1], content: [{ type: 'text', text: 'mine' }] }) + ); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + const r = await client.callTool({ name: 'list-numbers', arguments: {} }); + expect(r.isError).toBeFalsy(); + expect(r.structuredContent).toEqual([1, 2, 3]); + // The auto-TextContent fallback carries the JSON serialisation because + // the handler authored no `type:'text'` block of its own. + expect(r.content).toContainEqual({ type: 'text', text: JSON.stringify([1, 2, 3]) }); + + // Author opt-out: any author-supplied `type:'text'` block suppresses the + // auto-fallback — exactly the authored block, no JSON-stringify append. + const own = await client.callTool({ name: 'list-authored', arguments: {} }); + expect(own.structuredContent).toEqual([1]); + const textBlocks = (own.content ?? []).filter(c => c.type === 'text'); + expect(textBlocks).toEqual([{ type: 'text', text: 'mine' }]); +}); + +verifies('server:jsonschema:primitive-structured-content', async ({ transport }: TestArgs) => { + const makeServer = (): McpServer => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool( + 'count', + { + inputSchema: z.object({}), + outputSchema: fromJsonSchema({ type: 'number' }) + }, + () => ({ structuredContent: 0, content: [] }) + ); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + const r = await client.callTool({ name: 'count', arguments: {} }); + expect(r.isError).toBeFalsy(); + expect(r.structuredContent).toBe(0); + expect(r.content).toContainEqual({ type: 'text', text: '0' }); +}); + +verifies( + ['2025:jsonschema:non-object-output-wrapped', '2025:jsonschema:non-object-structured-content-wrapped'], + async ({ transport }: TestArgs) => { + // McpServer with a non-object-root outputSchema, served on the 2025 era + // (entryStateless arm). The legacy interop wraps the outputSchema in a + // `{type:'object',properties:{result:…},required:['result']}` envelope so + // 2025 clients can parse it, and wraps the structuredContent as + // `{result: }` so it satisfies the envelope. The auto-TextContent + // fallback also carries the natural JSON serialisation. + const makeServer = (): McpServer => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool( + 'list-numbers', + { + inputSchema: z.object({}), + outputSchema: fromJsonSchema({ type: 'array', items: { type: 'number' } }) + }, + () => ({ structuredContent: [1, 2, 3], content: [] }) + ); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + const { tools } = await client.listTools(); + const tool = tools.find(t => t.name === 'list-numbers'); + expect(tool).toBeDefined(); + // The non-object outputSchema is wrapped in the {result:…} envelope on the legacy projection. + expect(tool?.outputSchema).toMatchObject({ + type: 'object', + properties: { result: { type: 'array', items: { type: 'number' } } }, + required: ['result'] + }); + + // The tool stays callable on the legacy era: structuredContent is wrapped as + // {result:[1,2,3]} so it satisfies both the 2025 wire shape (object-only) and the + // wrapped outputSchema; the auto-TextContent fallback carries the natural value. + const r = await client.callTool({ name: 'list-numbers', arguments: {} }); + expect(r.isError).toBeFalsy(); + expect(r.structuredContent).toEqual({ result: [1, 2, 3] }); + expect(r.content).toContainEqual({ type: 'text', text: JSON.stringify([1, 2, 3]) }); + } +); + +/** + * McpServer with a typeless-root outputSchema (`anyOf:[object, string]` from `z.union`). The + * legacy wrap predicate is per-tool and follows the SCHEMA root (which is non-object → wraps), + * not the runtime value's shape — so on the 2025 era both the object branch `{a:1}` and the + * string branch `"x"` come back wrapped as `structuredContent.result`, and on the 2026 era both + * come back natural. + */ +function unionOutputServer(): McpServer { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool( + 'union-out', + { + inputSchema: z.object({ which: z.enum(['obj', 'str']) }), + outputSchema: z.union([z.object({ a: z.number() }), z.string()]) + }, + ({ which }) => ({ structuredContent: which === 'obj' ? { a: 1 } : 'x', content: [] }) + ); + return s; +} + +verifies('2025:jsonschema:wrap-follows-schema-not-value', async ({ transport }: TestArgs) => { + const client = newClient(); + await using _ = await wire(transport, unionOutputServer, client); + + const { tools } = await client.listTools(); + const wrapped = tools.find(t => t.name === 'union-out')?.outputSchema; + // The typeless `{anyOf:[…]}` root is wrapped in the {result:…} envelope on the legacy projection. + expect(wrapped).toMatchObject({ + type: 'object', + properties: { result: { anyOf: [{ type: 'object' }, { type: 'string' }] } }, + required: ['result'] + }); + + // BOTH branches — including the object-valued one — are wrapped as {result:…} so the + // result satisfies the wrapped schema (and the legacy client validates it). + const obj = await client.callTool({ name: 'union-out', arguments: { which: 'obj' } }); + expect(obj.isError).toBeFalsy(); + expect(obj.structuredContent).toEqual({ result: { a: 1 } }); + + const str = await client.callTool({ name: 'union-out', arguments: { which: 'str' } }); + expect(str.isError).toBeFalsy(); + expect(str.structuredContent).toEqual({ result: 'x' }); + // The string branch is a non-object value, so the era-agnostic auto-TextContent fires. + expect(str.content).toContainEqual({ type: 'text', text: '"x"' }); +}); + +verifies('server:jsonschema:union-output-natural', async ({ transport }: TestArgs) => { + const client = newClient(); + await using _ = await wire(transport, unionOutputServer, client); + + const { tools } = await client.listTools(); + // No wrap on the 2026 era — the natural typeless `{anyOf:[…]}` root is advertised. + expect(tools.find(t => t.name === 'union-out')?.outputSchema).toMatchObject({ + anyOf: [{ type: 'object' }, { type: 'string' }] + }); + + const obj = await client.callTool({ name: 'union-out', arguments: { which: 'obj' } }); + expect(obj.isError).toBeFalsy(); + expect(obj.structuredContent).toEqual({ a: 1 }); + + const str = await client.callTool({ name: 'union-out', arguments: { which: 'str' } }); + expect(str.isError).toBeFalsy(); + expect(str.structuredContent).toBe('x'); + // The auto-TextContent fallback applies on EVERY era for non-object values. + expect(str.content).toContainEqual({ type: 'text', text: '"x"' }); +}); + +verifies('2025:jsonschema:ref-rewrite-on-wrap', async ({ transport }: TestArgs) => { + // A non-object outputSchema with a same-document `$ref` (e.g. a recursive array). On the + // legacy era the schema is wrapped under `#/properties/result`, so the `$ref` JSON Pointer + // must be rewritten to keep resolving (`#/items` → `#/properties/result/items`). Mirrors the + // C# SDK's TransformOutputSchemaForLegacyWire. + const NATURAL = { + type: 'array', + items: { anyOf: [{ type: 'number' }, { $ref: '#' }] } + } as const; + const makeServer = (): McpServer => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool('tree', { inputSchema: z.object({}), outputSchema: fromJsonSchema(NATURAL) }, () => ({ + structuredContent: [1, [2, 3]], + content: [] + })); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + const { tools } = await client.listTools(); + const wrapped = tools.find(t => t.name === 'tree')?.outputSchema; + expect(wrapped).toMatchObject({ + type: 'object', + properties: { result: { type: 'array', items: { anyOf: [{ type: 'number' }, { $ref: '#/properties/result' }] } } }, + required: ['result'] + }); + + // The wrapped schema compiles on the client (the rewritten `$ref` resolves) and validates the + // wrapped structuredContent. + const r = await client.callTool({ name: 'tree', arguments: {} }); + expect(r.isError).toBeFalsy(); + expect(r.structuredContent).toEqual({ result: [1, [2, 3]] }); +}); + +verifies('2025:jsonschema:ref-rewrite-scope', async ({ transport }: TestArgs) => { + // The legacy-wrap `$ref` rewrite applies to `$ref` AND `$dynamicRef` in subschema positions, + // but NOT to instance-data positions (`const`/`enum`/`default`/`examples`) — a `{$ref:…}` + // there is a literal value. (The TypeScript SDK goes beyond the C# RewriteRefPointers on both.) + // + // Listing-only assertion: Ajv2020 stack-overflows compiling a `$dynamicRef` whose fragment is + // a JSON Pointer (rather than a `$dynamicAnchor`), so the tool is intentionally never called + // here — the rewrite contract is about what the wrapped SCHEMA looks like in tools/list. + const NATURAL = { + anyOf: [{ $dynamicRef: '#/$defs/X' }, { const: { $ref: '#/foo' } }], + $defs: { + X: { + type: 'object', + properties: { v: { type: 'number', default: { $ref: '#' }, examples: [{ $ref: '#/bar' }] } }, + required: ['v'] + } + } + } as const; + const makeServer = (): McpServer => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool('scope', { inputSchema: z.object({}), outputSchema: fromJsonSchema(NATURAL) }, () => ({ + structuredContent: { v: 7 }, + content: [] + })); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + const { tools } = await client.listTools(); + const wrapped = tools.find(t => t.name === 'scope')?.outputSchema; + expect(wrapped).toEqual({ + type: 'object', + properties: { + result: { + anyOf: [ + { $dynamicRef: '#/properties/result/$defs/X' }, // rewritten + { const: { $ref: '#/foo' } } // data position — NOT rewritten + ], + $defs: { + X: { + type: 'object', + properties: { + v: { + type: 'number', + default: { $ref: '#' }, // data position — NOT rewritten + examples: [{ $ref: '#/bar' }] // data position — NOT rewritten + } + }, + required: ['v'] + } + } + } + }, + required: ['result'] + }); +}); From 1c6bdaeb5663dc792a5979ce7cac69fada545bfa Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 23 Jun 2026 16:01:27 +0000 Subject: [PATCH 6/6] docs: SEP-2106/1613 migration + changesets + schema-validators example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit migration.md / migration-SKILL.md: new "JSON Schema 2020-12 posture" section covering the Ajv2020 default (with the v1 draft-07 opt-back recipe), the structuredContent: unknown source-level break and narrowing pattern, the legacy {result:…} wrap for non-object outputSchema / structuredContent (with the $ref-pointer rewrite), and the provably-object-shaped typeless-root stamping rule. External $ref is documented as not-dereferenced (unchanged from v1; engine MissingRefError, surfaced per-tool on callTool) — not a new break. client.md / server.md: structuredContent narrowing example; drop the obsolete "use a type alias not an interface" note (structuredContent is no longer index-signatured). schema-validators example: new list-forecasts tool (array outputSchema / structuredContent) demonstrating the auto TextContent fallback, the known-server cast idiom on the modern leg, and the {result:…} unwrap on the legacy leg. Changesets: sep-2106-dialect-posture (minor across core/client/server); client-response-cache-substrate updated for the per-tool InvalidParams behavior. --- .changeset/client-response-cache-substrate.md | 2 +- .changeset/sep-2106-dialect-posture.md | 7 ++ docs/client.md | 12 ++- docs/migration-SKILL.md | 14 +++- docs/migration.md | 74 ++++++++++++++++++- docs/server.md | 12 --- examples/schema-validators/README.md | 2 +- examples/schema-validators/client.ts | 33 ++++++++- examples/schema-validators/server.ts | 20 +++++ 9 files changed, 150 insertions(+), 26 deletions(-) create mode 100644 .changeset/sep-2106-dialect-posture.md diff --git a/.changeset/client-response-cache-substrate.md b/.changeset/client-response-cache-substrate.md index 9d957d5f0c..fc24016dc6 100644 --- a/.changeset/client-response-cache-substrate.md +++ b/.changeset/client-response-cache-substrate.md @@ -4,4 +4,4 @@ `Client.listTools()` / `listPrompts()` / `listResources()` / `listResourceTemplates()` now **auto-aggregate every page** when called without a `cursor` and return the complete result with `nextCursor: undefined` (matching the C#, Java, and mcp.d SDKs). Pass an explicit `{ cursor }` string to fetch a single page; the per-page path is unchanged. Existing manual pagination loops keep working — the first iteration returns everything and the loop exits — but can be deleted. The aggregated result is written to the new pluggable `ResponseCacheStore` (default: a fresh per-instance `InMemoryResponseCacheStore`); a `ClientResponseCache` collaborator owns the eviction-generation guard and the derived `tools/list` index that `callTool`'s output validation and SEP-2243 `Mcp-Param-*` mirroring read. New exports: `ResponseCacheStore`, `CacheKey`, `CacheEntry`, `CacheScope`, `MaybePromise`, `InMemoryResponseCacheStore`; new `ClientOptions.responseCacheStore` / `ClientOptions.listMaxPages` (caps the auto-aggregate walk at 64 pages by default; throws `SdkError` with `SdkErrorCode.ListPaginationExceeded` on overrun so a partial aggregate is never cached). The store interface is async-ready (`MaybePromise<…>`); the in-memory default stays synchronous. Entries are automatically scoped by the connected server's identity and (when set) the consumer-supplied `cachePartition`, so a shared store does not collide across servers or principals; evictions are likewise scoped to the connected server's partitions. -**Behavior change (every era):** output-schema validator compilation is now lazy — validators are compiled on the first `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()` — and non-throwing: an uncompilable `outputSchema` is `console.warn`-ed and validation is skipped for that tool only (previously `listTools()` threw). A pluggable `jsonSchemaValidator` provider therefore observes compilation at `callTool` time, not `listTools` time. The legacy-era `listTools()` path is unchanged at the wire level but is observably different at the validator-lifecycle level. +**Behavior change (every era):** output-schema validator compilation is now lazy — validators are compiled on the first `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()`. `listTools()` no longer throws on an uncompilable `outputSchema` (every tool stays listed; the compile failure is captured per-tool); calling `callTool()` on the affected tool throws `ProtocolError(InvalidParams, "Tool 'X' has an invalid outputSchema: …")` before the request is sent — output-schema validation is never silently skipped. A pluggable `jsonSchemaValidator` provider therefore observes compilation at `callTool` time, not `listTools` time. The legacy-era `listTools()` path is unchanged at the wire level but is observably different at the validator-lifecycle level. diff --git a/.changeset/sep-2106-dialect-posture.md b/.changeset/sep-2106-dialect-posture.md new file mode 100644 index 0000000000..5882666c0d --- /dev/null +++ b/.changeset/sep-2106-dialect-posture.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +SEP-1613 / SEP-2106 (JSON Schema 2020-12 posture): the Node default JSON Schema validator is now `Ajv2020` (true draft 2020-12) instead of the draft-07 `Ajv` class — `$defs`/`prefixItems`/`unevaluatedProperties`/`dependentRequired` are now enforced where they were previously silently ignored; to opt back, construct the draft-07 instance with the v1 defaults — `const ajv = new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }); addFormats(ajv);` — and pass `new AjvJsonSchemaValidator(ajv)`. Schemas declaring a `$schema` other than 2020-12 are rejected with a clear error rather than mis-validating. `outputSchema` may now have a non-object root and `CallToolResult.structuredContent` is widened to `unknown` (a deliberate source-level break for typed consumers — see the migration guide for the narrowing pattern). Toward 2025-era clients McpServer wraps a non-object `outputSchema` (and the matching `structuredContent`) in a `{result: …}` envelope so the tool stays callable, with same-document `$ref`/`$dynamicRef` pointers rewritten to keep resolving. Independently, on every era (the SEP's MUST applies regardless of client version), McpServer auto-appends a `TextContent` JSON serialisation when a handler returns non-object `structuredContent` without its own text block. The `structuredContent` presence check is `!== undefined` (not falsy) on both sides. Thanks @mattzcarey (#2249). diff --git a/docs/client.md b/docs/client.md index d87dd52050..25c231d432 100644 --- a/docs/client.md +++ b/docs/client.md @@ -269,7 +269,7 @@ const result = await client.callTool({ console.log(result.content); ``` -Tool results may include a `structuredContent` field — a machine-readable JSON object for programmatic use by the client application, complementing `content` which is for the LLM: +Tool results may include a `structuredContent` field — a machine-readable JSON value (any JSON type per SEP-2106) for programmatic use by the client application, complementing `content` which is for the LLM: ```ts source="../examples/guides/clientGuide.examples.ts#callTool_structuredOutput" const result = await client.callTool({ @@ -277,9 +277,13 @@ const result = await client.callTool({ arguments: { weightKg: 70, heightM: 1.75 } }); -// Machine-readable output for the client application -if (result.structuredContent) { - console.log(result.structuredContent); // e.g. { bmi: 22.86 } +// Machine-readable output for the client application. SEP-2106: structuredContent is +// `unknown` (any JSON value). Check for presence with `!== undefined` and narrow before use. +if (result.structuredContent !== undefined) { + const sc: unknown = result.structuredContent; // e.g. { bmi: 22.86 } + if (typeof sc === 'object' && sc !== null && 'bmi' in sc) { + console.log(sc.bmi); + } } ``` diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 37dfa8e64a..9ecf39467f 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -574,7 +574,7 @@ side: auto-fulfilment is on by default (`ClientOptions.inputRequired`, `maxRound `Client.listTools()` / `listPrompts()` / `listResources()` / `listResourceTemplates()` / `readResource()` now honour the server-stamped SEP-2549 `ttlMs`/`cacheScope`: a still-fresh cached entry is returned without a round trip. Opt-in by server hint — a server that sends `ttlMs: 0` (the SDK's default stamp) sees no behaviour change. Per-call override: pass `{ cacheMode: 'refresh' }` (always fetch and re-store) or `{ cacheMode: 'bypass' }` (fetch without touching the cache). Server `ttlMs` is clamped at 24 h (`MAX_CACHE_TTL_MS`). Entries are automatically scoped by connected-server identity; new `ClientOptions.cachePartition` (per-principal slot for `'private'`-scoped entries on a shared `responseCacheStore`; default `''`) and `ClientOptions.defaultCacheTtlMs` (TTL when the result lacks one, e.g. legacy-era responses; default `0`). `ResponseCacheStore` gained `delete(key)` (driven by `notifications/resources/updated`); `InMemoryResponseCacheStore` is now bounded (`{ maxEntries }`, default 512). -Output-schema validator compilation is now lazy (first `callTool()` against the cached `tools/list` entry) and non-throwing (an uncompilable `outputSchema` is `console.warn`-ed and validation is skipped for that tool only); `listTools()` no longer throws on an uncompilable `outputSchema`. Applies on every era — the legacy-era `listTools()` path is unchanged at the wire level only. +Output-schema validator compilation is now lazy (first `callTool()` against the cached `tools/list` entry); `listTools()` no longer throws on an uncompilable `outputSchema` — every tool stays listed and the compile failure is captured per-tool. Calling `callTool()` on the affected tool throws `ProtocolError(InvalidParams, "Tool 'X' has an invalid outputSchema: …")` before the request is sent (validation is never silently skipped). Applies on every era — the legacy-era `listTools()` path is unchanged at the wire level only. New (no v1 equivalent): `Client.connect(transport, { prior: DiscoverResult })` — zero-round-trip connect (2026-07-28+ only; throws `EraNegotiationFailed` otherwise). Probe once, persist `client.getDiscoverResult()` (`JSON.stringify`), feed to every worker. New exported type: `ConnectOptions` (extends `RequestOptions` with `prior?: DiscoverResult`). @@ -664,6 +664,18 @@ Validator behavior: `@modelcontextprotocol/{client,server}/validators/cf-worker` for `CfWorkerJsonSchemaValidator`. Importing from a subpath means the corresponding peer dep must be in your `package.json`. - To replace validation entirely, pass `jsonSchemaValidator: myCustomValidator` with your own implementation of the `jsonSchemaValidator` interface. +JSON Schema 2020-12 posture (SEP-1613 / SEP-2106): the default validator supports JSON Schema 2020-12 only (the spec's only MUST) — on Node it is now `Ajv2020` instead of draft-07 `Ajv`. Schemas declaring a different `$schema` are rejected with a clear `Error("…unsupported +dialect…")`; to validate other dialects, pass a pre-configured Ajv instance: `new AjvJsonSchemaValidator(new Ajv({...}))`. `CallToolResult.structuredContent` is typed `unknown` (was `{ [k: string]: unknown }`). The presence check is `!== undefined`, not falsy. External `$ref` +is not dereferenced (unchanged from v1; Ajv throws `MissingRefError` at compile, surfaced per-tool on `callTool`). Toward 2025-era clients a non-object `outputSchema`/`structuredContent` is wrapped in a `{result:…}` envelope. + +| v1 pattern | Mechanical fix | +| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `result.structuredContent.` / `result.structuredContent?.` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '' in sc) { sc. }` | +| `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` | +| relying on default `Ajv` being draft-07 | `const ajv = new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }); addFormats(ajv); new AjvJsonSchemaValidator(ajv)` (import `Ajv`, `addFormats` from `…/validators/ajv`) | +| draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas | +| `outputSchema` or `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` | + ## 15. Migration Steps (apply in this order) 1. Update `package.json`: `npm uninstall @modelcontextprotocol/sdk`, install the appropriate v2 packages diff --git a/docs/migration.md b/docs/migration.md index 3c21bb1671..e83a75e871 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -594,9 +594,9 @@ New `ClientOptions`: The `ResponseCacheStore` interface gained `delete(key)` (the per-URI invalidation `notifications/resources/updated` drives) — custom stores written against the alpha substrate need to add it. The default `InMemoryResponseCacheStore` is now bounded (default 512 entries, oldest-first eviction; configurable via `{ maxEntries }`). -**Output-schema validator lifecycle (every era):** validator compilation is now lazy — validators are compiled on the first `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()` — and non-throwing: an uncompilable `outputSchema` is `console.warn`-ed -and validation is skipped for that tool only. In v1, `listTools()` threw on an uncompilable `outputSchema`; now it succeeds, and a pluggable `jsonSchemaValidator` provider observes compilation at `callTool` time, not `listTools` time. The legacy-era `listTools()` path is -unchanged at the wire level but is observably different at the validator-lifecycle level. +**Output-schema validator lifecycle (every era):** validator compilation is now lazy — validators are compiled on the first `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()`. In v1, `listTools()` threw on an uncompilable `outputSchema`; now +`listTools()` succeeds (every tool stays listed) and the compile failure is captured per-tool. Calling `callTool()` on the affected tool then throws `ProtocolError(InvalidParams, "Tool 'X' has an invalid outputSchema: …")`, **before the request is sent** — output-schema +validation is never silently skipped. A pluggable `jsonSchemaValidator` provider observes compilation at `callTool` time, not `listTools` time. The legacy-era `listTools()` path is unchanged at the wire level but is observably different at the validator-lifecycle level. ### `InMemoryTransport` moved @@ -1445,6 +1445,74 @@ subpath in some files and rely on the default in others. To replace validation wholesale rather than customizing the built-in classes, implement the `jsonSchemaValidator` interface and pass your own implementation through the option above. +### JSON Schema 2020-12 posture (SEP-1613, SEP-2106) + +SEP-1613 (in the 2025-11-25 revision) declares JSON Schema **draft 2020-12** as the dialect for tool `inputSchema` / `outputSchema`, and SEP-2106 (2026-07-28 draft) widens both to the full 2020-12 vocabulary — `$defs`, `$ref`, `$anchor`, composition (`allOf`/`anyOf`/`oneOf`), +conditionals (`if`/`then`/`else`), `prefixItems`, `unevaluatedProperties` — and lifts the `type:"object"` root restriction on `outputSchema` and `structuredContent`. This SDK release brings the validator posture and the public types into line with both SEPs. + +#### Default validator is JSON Schema 2020-12 only + +The default validator supports **JSON Schema 2020-12 only** (the spec's only MUST). On Node it is now `Ajv2020` (`ajv/dist/2020`) instead of the draft-07 `Ajv` class; the Cloudflare Workers default was already 2020-12. Schemas declaring a different `$schema` are rejected with a +clear `Error("…unsupported dialect … 2020-12 only…")`. Nothing in your code changes unless you fall into one of three populations: + +- **You declared 2020-12 keywords (`$defs`, `prefixItems`, `unevaluatedProperties`, `dependentRequired`) in a server schema and they were silently ignored.** They are now enforced. If a previously "passing" tool input or output starts failing validation, the schema was always + wrong on the wire — fix the schema or the data. +- **You authored draft-07 idioms via `fromJsonSchema()`** (e.g. tuple `items: [...]` instead of `prefixItems`, draft-07 `definitions`). Port to 2020-12 spelling, or pass a draft-07 Ajv instance **as the second argument** — `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — built per the opt-back recipe below. The `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema()`-authored schemas (`fromJsonSchema()` compiles eagerly with the package-level default unless a validator is passed directly). +- **You imported `Ajv` from the SDK's validator subpath and relied on the re-export being the draft-07 class.** It still is — `Ajv` remains the draft-07 class (re-exported for the opt-back), but it is **no longer** what the SDK uses by default. + +To validate other dialects, pass a pre-configured Ajv instance: + +```typescript +import { Ajv, addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv'; + +// Opt back to the v1 (draft-07) default — accepted structurally; the $schema check is skipped. +const ajv = new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }); +addFormats(ajv); +const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { jsonSchemaValidator: new AjvJsonSchemaValidator(ajv) }); +``` + +#### External `$ref` is not dereferenced — use `#/$defs/…` or `#anchor` + +The SDK never dereferences external `$ref`/`$dynamicRef` (the spec MUST-NOT is "do not dereference", not "reject"). Neither built-in engine fetches: Ajv throws a `MissingRefError` at compile time on an unresolved external reference; `@cfworker/json-schema` leaves it +unresolved. This is unchanged from v1 — there is no new break here. What is new is that on the client, one tool whose schema the engine refuses to compile **does not poison `tools/list`**: every tool stays listed, and the compile failure surfaces lazily when `callTool` is +invoked on that tool, as `ProtocolError(InvalidParams, "Tool 'X' has an invalid outputSchema: …")`, before the request is sent. To author a reusable subschema, inline it under `$defs` and reference it with a same-document `#/$defs/Name` or `#anchor` fragment — those compile and +validate on both built-in engines. + +#### `CallToolResult.structuredContent` is now typed `unknown` + +SEP-2106 lifts the `type:"object"` root restriction on `outputSchema`, so `structuredContent` may legally be an array, a string, a number, a boolean, or `null`. The public TypeScript type is widened from `{ [key: string]: unknown }` to `unknown` to match. **This is a deliberate +source-level break** for typed consumers that previously indexed into `structuredContent` directly: that worked because the v1 type let you read any key as `unknown`, which was already a lie about what the value at that key was. `unknown` is the honest type for a generic host +that does not know the server's output schema at compile time — narrow at the call site: + +```typescript +const r = await client.callTool({ name: 'compute', arguments: { n: 7 } }); + +// SEP-2106 narrowing pattern: prove the shape, then read. +const sc = r.structuredContent; +if (typeof sc === 'object' && sc !== null && 'value' in sc && typeof sc.value === 'number') { + use(sc.value); +} +``` + +The presence check is `!== undefined`, not falsy: `null`, `0`, `false`, `""` are legal `structuredContent` values now and are validated against the tool's `outputSchema` like any other value (so a falsy value against an object-typed schema still fails — this is **not** a guard +weakening). Runtime validation against the cached `outputSchema` remains the safety net regardless of how you narrow on the TypeScript side. + +#### Non-object `outputSchema` and the legacy `{result:…}` wrap + +A tool may now register an `outputSchema` whose root is `type:"array"`, `type:"string"`, etc. Because the 2025-11-25 wire keeps `outputSchema`/`structuredContent` at their object/Record shapes for byte-identity, a non-object root is **2026-only vocabulary**. When such a tool is +listed toward a 2025-era client, McpServer **wraps** the `outputSchema` in a `{type:"object", properties:{result:}, required:["result"]}` envelope so legacy clients can parse and compile it; same-document `$ref` / `$dynamicRef` JSON Pointers inside the natural +schema are rewritten (`#` → `#/properties/result`, `#/…` → `#/properties/result/…`) so they keep resolving after the wrap. On `tools/call` toward the same 2025-era client, the matching `structuredContent` is wrapped as `{result:}` so the result satisfies the wrapped +schema. The wrap decision is made once per tool from the **schema's** root (and applied to every result) so the advertised schema and the result projection cannot diverge — a `z.union([z.object(...), z.string()])` outputSchema wraps **both** branches as `{result:…}` on the +2025 era. 2026-era clients see the natural schema and the natural value — no envelope. This matches the C# SDK's `TransformOutputSchemaForLegacyWire` behavior. + +Independently, **on every era** (the SEP's MUST applies regardless of client version), when a handler returns non-object `structuredContent` and no `type:"text"` content of its own, McpServer auto-appends `{ type: "text", text: JSON.stringify(structuredContent) }` so consumers +that read only `content` still receive a rendering — author any `text` block yourself to opt out. + +**Typeless-root output schemas are only stamped `type:"object"` when provably safe.** A Standard-Schema value whose JSON Schema root has no `type` — for example `z.union([z.string(), z.number()])` (`{anyOf:[…]}`), `z.any()` (`{}`), or `z.object({…}).nullable()` — is advertised +as-is on the 2026 era and wrapped in `{result:…}` on the 2025 projection, because stamping `type:"object"` there would produce a self-contradictory schema that rejects every value. The SDK still defaults `type:"object"` when the root carries object keywords +(`properties`/`patternProperties`/`additionalProperties`/`required`) **or** is a `oneOf`/`anyOf`/`allOf` whose every member is `type:"object"` — so `z.discriminatedUnion(...)`, `z.union([z.object(...), …])`, and `z.intersection(...)` of objects keep their 2025-era advertisement +unchanged. + ## Specification clarifications adopted (no SDK behavior change) The 2026-07-28 specification revision includes a number of documentation-only clarifications that do not change SDK wire behavior or public surface. They are recorded here so an audit of the revision's changelog against this guide is complete; nothing in this section requires diff --git a/docs/server.md b/docs/server.md index 47d866aa6e..58da68597f 100644 --- a/docs/server.md +++ b/docs/server.md @@ -128,18 +128,6 @@ server.registerTool( ); ``` -> [!NOTE] -> When defining a named type for `structuredContent`, use a `type` alias rather than an `interface`. Named interfaces lack implicit index signatures in TypeScript, so they aren't assignable to `{ [key: string]: unknown }`: -> -> ```ts -> type BmiResult = { bmi: number }; // assignable -> interface BmiResult { -> bmi: number; -> } // type error -> ``` -> -> Alternatively, spread the value: `structuredContent: { ...result }`. - ### `ResourceLink` outputs Tools can return `resource_link` content items to reference large resources without embedding them, letting clients fetch only what they need: diff --git a/examples/schema-validators/README.md b/examples/schema-validators/README.md index ea83e07a61..4ee9673b53 100644 --- a/examples/schema-validators/README.md +++ b/examples/schema-validators/README.md @@ -1,6 +1,6 @@ # schema-validators -Tool input/output schemas via Zod, ArkType and Valibot — any Standard-Schema-with-JSON library works. Also shows `outputSchema` → `structuredContent`. +Tool input/output schemas via Zod, ArkType and Valibot — any Standard-Schema-with-JSON library works. Also shows `outputSchema` → `structuredContent`, including an array-root `outputSchema` (SEP-2106) with the auto-injected `TextContent` fallback and the client-side `unknown` runtime-narrowing pattern. ```bash pnpm tsx examples/schema-validators/client.ts diff --git a/examples/schema-validators/client.ts b/examples/schema-validators/client.ts index 60bf559258..86730c3eeb 100644 --- a/examples/schema-validators/client.ts +++ b/examples/schema-validators/client.ts @@ -19,11 +19,36 @@ runClient('schema-validators', async () => { check.match(result.content?.[0]?.type === 'text' ? result.content[0].text : '', /Hello, world!/); } + // structuredContent is typed `unknown` (SEP-2106). The SDK has already + // runtime-validated it against the server's outputSchema. This client is + // written FOR the paired server above, so the shape is known and a cast is + // the honest known-server idiom (same as C# `.Deserialize()` or Go + // `json.Unmarshal`). A generic host that connects to arbitrary servers + // would not cast; it would render the JSON or narrow at runtime. const weather = await client.callTool({ name: 'get-weather', arguments: { city: 'Tokyo' } }); - const sc = weather.structuredContent as { city?: string; conditions?: string; celsius?: number } | undefined; - check.equal(sc?.city, 'Tokyo'); - check.equal(sc?.conditions, 'sunny'); - check.equal(sc?.celsius, 21); + const w = weather.structuredContent as { city: string; conditions: string; celsius: number }; + check.equal(w.city, 'Tokyo'); + check.equal(w.conditions, 'sunny'); + check.equal(w.celsius, 21); + + // SEP-2106: array structuredContent. The SDK auto-injects a serialized + // JSON text block alongside it. On the legacy era the array is wrapped as + // `{result: }` (the 2025 wire shape only carries object + // structuredContent), so the natural value is at `.result`. + const forecasts = await client.callTool({ name: 'list-forecasts', arguments: { city: 'Tokyo' } }); + const text = forecasts.content?.find(c => c.type === 'text'); + check.ok(text, 'auto-injected TextContent fallback present'); + check.match(text.text, /"hour":"09:00"/); + type Forecast = { hour: string; celsius: number }; + if (process.argv.includes('--legacy')) { + const sc = forecasts.structuredContent as { result: Forecast[] }; + check.equal(sc.result.length, 2); + check.equal(sc.result[0]?.hour, '09:00'); + } else { + const sc = forecasts.structuredContent as Forecast[]; + check.equal(sc.length, 2); + check.equal(sc[0]?.hour, '09:00'); + } await client.close(); }); diff --git a/examples/schema-validators/server.ts b/examples/schema-validators/server.ts index bdca9c8c17..1a04f40f78 100644 --- a/examples/schema-validators/server.ts +++ b/examples/schema-validators/server.ts @@ -48,6 +48,26 @@ function buildServer(): McpServer { } ); + // SEP-2106: outputSchema may have any JSON Schema root (here an array), and + // structuredContent may be any JSON value. When structuredContent is not an + // object and the handler returns no text block, the SDK injects a serialized + // JSON text block so legacy clients have something to read. + server.registerTool( + 'list-forecasts', + { + description: 'Hourly forecast (array structuredContent)', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.array(z.object({ hour: z.string(), celsius: z.number() })) + }, + async () => ({ + content: [], + structuredContent: [ + { hour: '09:00', celsius: 18 }, + { hour: '10:00', celsius: 21 } + ] + }) + ); + return server; }