From 26564b0956a06197abbaf704d1a4634d1829909c Mon Sep 17 00:00:00 2001 From: blue Date: Sat, 31 Oct 2026 14:40:49 +0800 Subject: [PATCH] Use an already-converted JSON Schema document directly Zod's toJSONSchema() retains a ~standard prop on its output whose converters re-run the conversion from the original zod schema, so such a document passed to registerTool()/registerPrompt() was re-converted on every tools/list and toolInputSchemaJson() call (#3132). A schema carrying $schema or properties is now recognized as a document and used as-is; live schemas never carry those keywords (zod: def, valibot: entries, ArkType nodes are functions). --- .../preconverted-json-schema-direct-use.md | 8 +++++ .../core-internal/src/util/standardSchema.ts | 20 ++++++++++- .../test/util/standardSchema.test.ts | 33 +++++++++++++++++++ .../server/lazyToolSchemaConversion.test.ts | 22 +++++++++++++ 4 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 .changeset/preconverted-json-schema-direct-use.md diff --git a/.changeset/preconverted-json-schema-direct-use.md b/.changeset/preconverted-json-schema-direct-use.md new file mode 100644 index 0000000000..531ce23c88 --- /dev/null +++ b/.changeset/preconverted-json-schema-direct-use.md @@ -0,0 +1,8 @@ +--- +'@modelcontextprotocol/core-internal': patch +'@modelcontextprotocol/server': patch +--- + +A schema passed to `registerTool` / `registerPrompt` that is itself an already-converted JSON Schema document — e.g. the output of zod's `toJSONSchema()`, which retains a `~standard` prop — is now used directly instead of being re-converted through the original schema's converters on every `tools/list` and `toolInputSchemaJson()` call. + +The same direct use applies to an elicitation `requestedSchema`: a document carrying root-level constraints elicitation does not support (e.g. `additionalProperties`) now fails with the documented unsupported-constraint error — the same treatment a raw JSON Schema already receives — where the previous re-conversion silently dropped them. diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index ebaaa57572..802042097d 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -169,8 +169,24 @@ let warnedZodFallback = false; /** JSON Schema draft targeted by every conversion; shared so pattern references above stay in lockstep. */ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; +/** + * Whether `schema` is itself an already-converted JSON Schema document rather than a live + * Standard Schema. Zod's `toJSONSchema()` retains a `~standard` prop on its output whose + * converters re-run the conversion from the original schema, so such a document must be + * used directly instead of re-converted (#3132). Live schemas never carry these keywords + * (zod: `def`, valibot: `entries`, ArkType nodes are functions), so `$schema`/`properties` + * cannot misfire on one. + */ +function isJsonSchemaDocument(schema: StandardJSONSchemaV1): boolean { + const { $schema, properties } = schema as { $schema?: unknown; properties?: unknown }; + if (typeof $schema === 'string') return true; + return properties !== null && typeof properties === 'object'; +} + /** * Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema. + * A schema that is itself a JSON Schema document (e.g. zod's `toJSONSchema()` + * output) is used directly rather than re-converted. * * MCP requires `type: "object"` at the root of tool `inputSchema` and prompt * argument schemas; `outputSchema` may have any JSON Schema root (SEP-2106). @@ -183,7 +199,9 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'input' | 'output' = 'input'): Record { const std = schema['~standard']; let result: Record; - if (std.jsonSchema) { + if (isJsonSchemaDocument(schema)) { + result = schema as unknown as Record; + } else if (std.jsonSchema) { result = std.jsonSchema[io]({ target: JSON_SCHEMA_CONVERSION_TARGET }); } else if (std.vendor === 'zod') { // zod 4.0–4.1 implements StandardSchemaV1 but not StandardJSONSchemaV1 (`~standard.jsonSchema`). diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 8856592ff0..6afbd7bf8a 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -2,6 +2,18 @@ import * as z from 'zod/v4'; import { standardSchemaToJsonSchema } from '../../src/util/standardSchema'; +/** Patches `~standard.jsonSchema.input` with a counter (#3132: the prop is readonly in zod's types). */ +function countInputConversions(schema: { '~standard': unknown }): () => number { + let count = 0; + const jsonSchema = (schema['~standard'] as { jsonSchema: Record<'input', (options: never) => unknown> }).jsonSchema; + const original = jsonSchema.input; + jsonSchema.input = (options: never) => { + count++; + return original(options); + }; + return () => count; +} + describe('standardSchemaToJsonSchema', () => { test('emits type:object for plain z.object schemas', () => { const schema = z.object({ name: z.string(), age: z.number() }); @@ -39,4 +51,25 @@ describe('standardSchemaToJsonSchema', () => { expect(keys.filter(k => k === 'type')).toHaveLength(1); expect(result.type).toBe('object'); }); + + test('uses an already-converted toJSONSchema() document directly instead of re-converting', () => { + const document = z.object({ a: z.string() }).toJSONSchema(); + const conversions = countInputConversions(document); + + const result = standardSchemaToJsonSchema(document, 'input'); + + expect(conversions()).toBe(0); + expect(result).toEqual({ type: 'object', ...document }); + }); + + test('still converts a live zod schema carrying ~standard', () => { + const schema = z.object({ a: z.string() }); + const conversions = countInputConversions(schema); + + const result = standardSchemaToJsonSchema(schema, 'input'); + + expect(conversions()).toBe(1); + expect(result.type).toBe('object'); + expect(result.properties).toEqual({ a: { type: 'string' } }); + }); }); diff --git a/packages/server/test/server/lazyToolSchemaConversion.test.ts b/packages/server/test/server/lazyToolSchemaConversion.test.ts index 073ce16f87..b451f65c1f 100644 --- a/packages/server/test/server/lazyToolSchemaConversion.test.ts +++ b/packages/server/test/server/lazyToolSchemaConversion.test.ts @@ -169,4 +169,26 @@ describe('lazy tool schema conversion', () => { expect(body.result).toBeUndefined(); expect(JSON.stringify(body.error)).toContain('does not implement StandardJSONSchemaV1'); }); + + it('an already-converted JSON Schema document (zod toJSONSchema() output) is used directly, never re-converted', async () => { + const document = z.object({ a: z.string() }).toJSONSchema() as unknown as StandardSchemaWithJSON; + // The document retains a `~standard` prop whose converters re-run the conversion + // from the original zod schema; patch them so any re-conversion is counted (#3132). + let conversions = 0; + const jsonSchema = (document['~standard'] as { jsonSchema: Record<'input', (options: never) => unknown> }).jsonSchema; + const original = jsonSchema.input; + jsonSchema.input = (options: never) => { + conversions++; + return original(options); + }; + const server = new McpServer({ name: 'lazy', version: '0' }); + server.registerTool('t', { inputSchema: document }, async () => ({ content: [] })); + + expect(server.toolInputSchemaJson('t')).toEqual({ type: 'object', ...document }); + expect(conversions).toBe(0); + + // tools/list converts outside the memo, and must not re-convert a document either. + await listTools(server); + expect(conversions).toBe(0); + }); });