Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/preconverted-json-schema-direct-use.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 19 additions & 1 deletion packages/core-internal/src/util/standardSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -183,7 +199,9 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12';
export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'input' | 'output' = 'input'): Record<string, unknown> {
const std = schema['~standard'];
let result: Record<string, unknown>;
if (std.jsonSchema) {
if (isJsonSchemaDocument(schema)) {
result = schema as unknown as Record<string, unknown>;
} 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`).
Expand Down
33 changes: 33 additions & 0 deletions packages/core-internal/test/util/standardSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() });
Expand Down Expand Up @@ -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' } });
});
});
22 changes: 22 additions & 0 deletions packages/server/test/server/lazyToolSchemaConversion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading