From 6bf76c0fbc6677935cb30dea02606dd1199be490 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 07:56:12 +0000 Subject: [PATCH 1/4] Recover .describe() descriptions in the zod <4.2 JSON Schema fallback Zod stores .describe() text in a per-instance metadata registry, so when a schema comes from a zod version without ~standard.jsonSchema (zod 4.0/4.1, or the zod@3.25.x zod/v4 subpath), converting it with the SDK-bundled z.toJSONSchema() silently dropped every field description from the advertised tools/list schema. The schema's own .description getters still work across instances, so walk the schema alongside the converted JSON Schema (object properties, array elements, optional/nullable/default wrappers) and fill in anything the converter missed. Existing descriptions are never overwritten, foreign getters are treated as untrusted, and recovery can never break conversion. Also allow silencing the one-time fallback warning via MCP_SUPPRESS_ZOD_FALLBACK_WARNING for applications that cannot emit stray console output, and update the warning text to say what is and is not preserved. Tests use a real second zod instance (npm alias zod-v40 -> zod@4.0.17) rather than mocks, so the cross-registry behavior is exercised for real. --- packages/core/package.json | 13 +- packages/core/src/util/standardSchema.ts | 126 +++++++++++++++++- ...ndardSchema.zodForeignDescriptions.test.ts | 122 +++++++++++++++++ pnpm-lock.yaml | 8 ++ 4 files changed, 261 insertions(+), 8 deletions(-) create mode 100644 packages/core/test/util/standardSchema.zodForeignDescriptions.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index beb46ccb88..58becbb000 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -77,13 +77,11 @@ } }, "devDependencies": { - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", "@cfworker/json-schema": "catalog:runtimeShared", - "ajv": "catalog:runtimeShared", - "ajv-formats": "catalog:runtimeShared", "@eslint/js": "catalog:devTools", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", "@types/content-type": "catalog:devTools", "@types/cors": "catalog:devTools", "@types/cross-spawn": "catalog:devTools", @@ -91,6 +89,8 @@ "@types/express": "catalog:devTools", "@types/express-serve-static-core": "catalog:devTools", "@typescript/native-preview": "catalog:devTools", + "ajv": "catalog:runtimeShared", + "ajv-formats": "catalog:runtimeShared", "eslint": "catalog:devTools", "eslint-config-prettier": "catalog:devTools", "eslint-plugin-n": "catalog:devTools", @@ -98,6 +98,7 @@ "tsx": "catalog:devTools", "typescript": "catalog:devTools", "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" + "vitest": "catalog:devTools", + "zod-v40": "npm:zod@4.0.17" } } diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index b938885de0..d22119c096 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -164,6 +164,125 @@ export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSch let warnedZodFallback = false; +function isZodFallbackWarningSuppressed(): boolean { + // Core must stay runtime-neutral (browser / Workers), so reach for `process` defensively. + try { + const env = (globalThis as { process?: { env?: Record } }).process?.env; + const value = env?.MCP_SUPPRESS_ZOD_FALLBACK_WARNING; + return value !== undefined && value !== '' && value !== '0' && value !== 'false'; + } catch { + return false; + } +} + +function readForeignDescription(node: unknown): string | undefined { + // `.description` is a getter that runs the schema's own zod code against its own + // metadata registry, so it works across zod instances where the bundled converter + // cannot. Foreign getters are untrusted: never let them break conversion. + try { + const description = (node as { description?: unknown }).description; + return typeof description === 'string' && description.length > 0 ? description : undefined; + } catch { + return undefined; + } +} + +function unwrapForeignSchema(node: unknown): unknown { + // Wrappers like .optional()/.nullable()/.default() carry their own registry entry; + // a .describe() applied before wrapping lives on the inner schema instead. + try { + const def = (node as { _zod?: { def?: { innerType?: unknown } } })._zod?.def; + return def?.innerType; + } catch { + return undefined; + } +} + +function readForeignDescriptionDeep(node: unknown): string | undefined { + let current: unknown = node; + for (let depth = 0; depth < 8 && current != null; depth++) { + const description = readForeignDescription(current); + if (description !== undefined) return description; + current = unwrapForeignSchema(current); + } + return undefined; +} + +function foreignShape(node: unknown): Record | undefined { + let current: unknown = node; + for (let depth = 0; depth < 8 && current != null; depth++) { + try { + const shape = (current as { shape?: unknown }).shape; + if (shape != null && typeof shape === 'object') return shape as Record; + } catch { + return undefined; + } + current = unwrapForeignSchema(current); + } + return undefined; +} + +function foreignElement(node: unknown): unknown { + let current: unknown = node; + for (let depth = 0; depth < 8 && current != null; depth++) { + try { + const element = (current as { element?: unknown }).element; + if (element != null) return element; + } catch { + return undefined; + } + current = unwrapForeignSchema(current); + } + return undefined; +} + +/** + * Best-effort recovery of `.describe()` metadata after converting a foreign zod + * instance's schema with the SDK-bundled `z.toJSONSchema()`. + * + * Zod stores `.describe()` text in a per-instance metadata registry, so the bundled + * converter silently drops every description attached through a different zod instance + * (zod 4.0/4.1, or the zod@3.25.x `zod/v4` subpath). The schema's own `.description` + * getters still work, so walk the schema alongside the converted JSON Schema and fill + * in any descriptions the converter missed. Existing descriptions are never overwritten. + */ +function recoverForeignDescriptions( + schema: unknown, + jsonSchema: Record, + visited = new WeakSet(), + depth = 0 +): void { + if (depth > 16 || schema == null || typeof schema !== 'object') return; + if (visited.has(schema)) return; + visited.add(schema); + + if (jsonSchema.description === undefined) { + const description = readForeignDescriptionDeep(schema); + if (description !== undefined) jsonSchema.description = description; + } + + const properties = jsonSchema.properties; + if (properties != null && typeof properties === 'object') { + const shape = foreignShape(schema); + if (shape) { + for (const [key, fieldSchema] of Object.entries(shape)) { + const fieldJson = (properties as Record)[key]; + if (fieldJson != null && typeof fieldJson === 'object') { + recoverForeignDescriptions(fieldSchema, fieldJson as Record, visited, depth + 1); + } + } + } + } + + const items = jsonSchema.items; + if (items != null && typeof items === 'object' && !Array.isArray(items)) { + const element = foreignElement(schema); + if (element != null) { + recoverForeignDescriptions(element, items as Record, visited, depth + 1); + } + } +} + /** * Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema. * @@ -190,14 +309,17 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in 'Upgrade to zod >=4.2.0, or wrap your JSON Schema with fromJsonSchema().' ); } - if (!warnedZodFallback) { + if (!warnedZodFallback && !isZodFallbackWarningSuppressed()) { warnedZodFallback = true; console.warn( '[mcp-sdk] Your zod version does not implement `~standard.jsonSchema` (added in zod 4.2.0). ' + - 'Falling back to z.toJSONSchema(). Upgrade to zod >=4.2.0 to silence this warning.' + 'Falling back to the bundled converter; `.describe()` descriptions are recovered on a best-effort ' + + 'basis but other registry metadata (`.meta()`) may be lost. Upgrade to zod >=4.2.0 for full ' + + 'fidelity, or set MCP_SUPPRESS_ZOD_FALLBACK_WARNING=1 to silence this warning.' ); } result = z.toJSONSchema(schema as unknown as z.ZodType, { target: 'draft-2020-12', io }) as Record; + recoverForeignDescriptions(schema, result); } else { throw new Error( `Schema library "${std.vendor}" does not implement StandardJSONSchemaV1 (\`~standard.jsonSchema\`). ` + diff --git a/packages/core/test/util/standardSchema.zodForeignDescriptions.test.ts b/packages/core/test/util/standardSchema.zodForeignDescriptions.test.ts new file mode 100644 index 0000000000..65234cc2ca --- /dev/null +++ b/packages/core/test/util/standardSchema.zodForeignDescriptions.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as zOld from 'zod-v40'; +import * as z from 'zod/v4'; + +import { standardSchemaToJsonSchema } from '../../src/util/standardSchema.js'; + +type SchemaArg = Parameters[0]; + +// `zod-v40` is an npm alias for zod@4.0.x: a real second zod instance that implements +// StandardSchemaV1 but not `~standard.jsonSchema` (added in 4.2). Because zod stores +// `.describe()` text in a per-instance global registry, the SDK-bundled `z.toJSONSchema()` +// cannot see any metadata attached through a foreign instance — exactly the situation of +// an application that depends on zod 4.0/4.1 (or the zod@3.25.x `zod/v4` subpath) while +// the SDK bundles its own zod. These tests pin the fallback's behavior for that case. + +function props(result: Record): Record { + return result.properties as Record; +} + +describe('standardSchemaToJsonSchema — foreign zod instance description recovery', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('recovers .describe() metadata that the bundled converter cannot see', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const schema = zOld + .object({ + name: zOld.string().describe('the display name'), + age: zOld.number().optional().describe('age in years'), + nickname: zOld.string().describe('preferred short name').optional(), + address: zOld + .object({ + street: zOld.string().describe('street and house number') + }) + .describe('postal address'), + tags: zOld.array(zOld.string().describe('a single tag')).describe('all tags') + }) + .describe('a person'); + + const result = standardSchemaToJsonSchema(schema as unknown as SchemaArg); + + expect(result.description).toBe('a person'); + expect(props(result).name?.description).toBe('the display name'); + // .describe() applied after .optional() (registry entry on the wrapper) + expect(props(result).age?.description).toBe('age in years'); + // .describe() applied before .optional() (registry entry on the inner schema) + expect(props(result).nickname?.description).toBe('preferred short name'); + expect(props(result).address?.description).toBe('postal address'); + const address = props(result).address as { properties?: Record }; + expect(address.properties?.street?.description).toBe('street and house number'); + expect(props(result).tags?.description).toBe('all tags'); + expect(props(result).tags?.items?.description).toBe('a single tag'); + }); + + it('does not overwrite descriptions the converter already produced', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + // Bundled-instance schema with `~standard.jsonSchema` hidden: the converter shares + // the schema's registry, so its own description survives conversion and must win. + const real = z.object({ a: z.string().describe('from converter') }); + const { jsonSchema: _drop, ...stdNoJson } = real['~standard'] as unknown as Record; + void _drop; + Object.defineProperty(real, '~standard', { value: { ...stdNoJson, vendor: 'zod' }, configurable: true }); + + const result = standardSchemaToJsonSchema(real as unknown as SchemaArg); + expect(props(result).a?.description).toBe('from converter'); + }); + + it('recovery never throws, even when foreign getters do', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const schema = zOld.object({ a: zOld.string() }); + Object.defineProperty(schema, 'description', { + get() { + throw new Error('hostile getter'); + }, + configurable: true + }); + + expect(() => standardSchemaToJsonSchema(schema as unknown as SchemaArg)).not.toThrow(); + }); + + it('zod 3 schemas still get the clear upgrade error', () => { + const zod3ish = { _def: {}, '~standard': { version: 1, vendor: 'zod', validate: () => ({ value: {} }) } }; + expect(() => standardSchemaToJsonSchema(zod3ish as unknown as SchemaArg)).toThrow(/zod 3/); + }); +}); + +describe('standardSchemaToJsonSchema — fallback warning controls', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + vi.resetModules(); + }); + + async function freshConvert(schema: unknown): Promise { + // Fresh module instance so the once-per-process warning flag is reset. + vi.resetModules(); + const warnings: string[] = []; + const warn = vi.spyOn(console, 'warn').mockImplementation((msg: unknown) => { + warnings.push(String(msg)); + }); + const mod = await import('../../src/util/standardSchema.js'); + mod.standardSchemaToJsonSchema(schema as Parameters[0]); + mod.standardSchemaToJsonSchema(schema as Parameters[0]); + warn.mockRestore(); + return warnings; + } + + it('warns once per process and points at zod 4.2.0', async () => { + const warnings = await freshConvert(zOld.object({ a: zOld.string() })); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('zod 4.2.0'); + expect(warnings[0]).toContain('MCP_SUPPRESS_ZOD_FALLBACK_WARNING'); + }); + + it('is silenced by MCP_SUPPRESS_ZOD_FALLBACK_WARNING', async () => { + vi.stubEnv('MCP_SUPPRESS_ZOD_FALLBACK_WARNING', '1'); + const warnings = await freshConvert(zOld.object({ a: zOld.string() })); + expect(warnings).toHaveLength(0); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c13f1ebcde..09ae330c9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -711,6 +711,9 @@ importers: vitest: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + zod-v40: + specifier: npm:zod@4.0.17 + version: zod@4.0.17 packages/middleware/express: dependencies: @@ -5319,6 +5322,9 @@ packages: peerDependencies: zod: ^3.25.28 || ^4 + zod@4.0.17: + resolution: {integrity: sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -9360,4 +9366,6 @@ snapshots: dependencies: zod: 4.3.6 + zod@4.0.17: {} + zod@4.3.6: {} From 304f331ce33ed25463167dfa32ae5e46cd28d02d Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 07:56:12 +0000 Subject: [PATCH 2/4] Document the zod <4.2 JSON Schema fallback and its limits Add a migration-guide subsection and a zod version matrix covering: native conversion on zod >=4.2, best-effort description recovery + one-time warning (and MCP_SUPPRESS_ZOD_FALLBACK_WARNING) on 4.0/4.1-era lineages, and the clear error for zod 3 classic schemas, with a wrapper recipe that supplies the converter from the consumer's own zod instance. --- .../zod-fallback-description-recovery.md | 8 ++++++ docs/migration-SKILL.md | 8 ++++++ docs/migration.md | 28 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 .changeset/zod-fallback-description-recovery.md diff --git a/.changeset/zod-fallback-description-recovery.md b/.changeset/zod-fallback-description-recovery.md new file mode 100644 index 0000000000..0951438391 --- /dev/null +++ b/.changeset/zod-fallback-description-recovery.md @@ -0,0 +1,8 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Recover `.describe()` descriptions when converting schemas from zod versions without `~standard.jsonSchema` (zod 4.0/4.1 and the zod@3.25.x `zod/v4` subpath). The bundled-converter fallback previously dropped all registry-held metadata, silently advertising tool schemas without +any field documentation. The fallback warning can now be silenced with `MCP_SUPPRESS_ZOD_FALLBACK_WARNING=1` and mentions what is and isn't preserved. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index b849da8b3d..eb03814e00 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -225,6 +225,14 @@ The variadic `.tool()`, `.prompt()`, `.resource()` methods are removed. Use the **IMPORTANT**: v2 requires schema objects implementing [Standard Schema](https://standardschema.dev/) — raw shapes like `{ name: z.string() }` are no longer supported. Wrap with `z.object()` (Zod v4), or use ArkType's `type({...})`, or Valibot. For raw JSON Schema, wrap with `fromJsonSchema(schema)` from `@modelcontextprotocol/server` (validator defaults automatically; pass an explicit validator for custom configurations). Applies to `inputSchema`, `outputSchema`, and `argsSchema`. +**Zod version matrix** for `inputSchema`/`outputSchema`/`argsSchema` values: + +| Schema's zod lineage | Behavior in v2 | +| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| zod >=4.2 (`~standard.jsonSchema` present) | Native conversion, full fidelity | +| zod 4.0 / 4.1, zod@3.25.x `zod/v4` subpath | Bundled-converter fallback + one-time warning (silence: `MCP_SUPPRESS_ZOD_FALLBACK_WARNING=1`); `.describe()` recovered best-effort, `.meta()` may be lost | +| zod 3 classic (`zod` import from zod@3.x) | Error at `tools/list` — upgrade to zod 4 or use `fromJsonSchema()` | + ### Tools ```typescript diff --git a/docs/migration.md b/docs/migration.md index bf88cf2b76..2bbd545870 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -313,6 +313,34 @@ This applies to: | `SchemaInput` | `StandardSchemaWithJSON.InferInput` | | `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema` | No replacement — these are now internal Zod introspection helpers | +#### Using zod 4.0 / 4.1 (or the zod@3.25.x `zod/v4` subpath) + +`~standard.jsonSchema` was added in zod 4.2.0. Older zod 4 lineages implement Standard Schema validation but not JSON Schema conversion, so the SDK falls back to converting your schema with its own bundled zod. Because zod stores `.describe()` text in a per-instance metadata +registry, the bundled converter cannot see metadata attached through your zod instance — the SDK recovers `.describe()` descriptions on a best-effort basis (top level, object properties, array elements, and `.optional()`/`.nullable()`/`.default()` wrappers), but other registry +metadata attached via `.meta()` may be lost. + +The fallback logs a one-time warning. Silence it with `MCP_SUPPRESS_ZOD_FALLBACK_WARNING=1`, or upgrade to zod >=4.2.0 for full fidelity. If upgrading isn't an option, you can supply the converter from your own zod instance, which preserves all metadata: + +```typescript +import * as z from 'zod/v4'; // your zod, any 4.x lineage + +function withJsonSchema(schema: T) { + return { + '~standard': { + ...schema['~standard'], + jsonSchema: { + input: () => z.toJSONSchema(schema, { target: 'draft-2020-12', io: 'input' }), + output: () => z.toJSONSchema(schema, { target: 'draft-2020-12', io: 'output' }) + } + } + }; +} + +server.registerTool('greet', { inputSchema: withJsonSchema(z.object({ name: z.string().describe('who to greet') })) }, handler); +``` + +zod 3 schemas (the classic `zod` import from zod@3.x) cannot be converted at all and produce a clear error at `tools/list` time — upgrade to zod 4, or describe your input with `fromJsonSchema()` instead. + ### Host header validation moved Express-specific middleware (`hostHeaderValidation()`, `localhostHostValidation()`) moved from the server package to `@modelcontextprotocol/express`. The server package now exports framework-agnostic functions instead: `validateHostHeader()`, `localhostAllowedHostnames()`, From 42e765943c197d82dac598b18cbed92cb74b4d98 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 08:23:03 +0000 Subject: [PATCH 3/4] Add wire-level test for foreign-zod description recovery and strengthen precedence test The no-overwrite test previously asserted a value that the recovery walk would have produced anyway, so it could not distinguish preserved from overwritten descriptions; the field getter now reports a different string than the converter emits. The new server test pins what a connected peer sees in tools/list when a tool is registered with a schema from a zod release without ~standard.jsonSchema: descriptions survive in the advertised schema and validated calls still round-trip. --- ...ndardSchema.zodForeignDescriptions.test.ts | 4 + packages/server/package.json | 3 +- .../server/mcp.foreignZodDescriptions.test.ts | 93 +++++++++++++++++++ pnpm-lock.yaml | 3 + 4 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 packages/server/test/server/mcp.foreignZodDescriptions.test.ts diff --git a/packages/core/test/util/standardSchema.zodForeignDescriptions.test.ts b/packages/core/test/util/standardSchema.zodForeignDescriptions.test.ts index 65234cc2ca..769bb902c1 100644 --- a/packages/core/test/util/standardSchema.zodForeignDescriptions.test.ts +++ b/packages/core/test/util/standardSchema.zodForeignDescriptions.test.ts @@ -62,6 +62,10 @@ describe('standardSchemaToJsonSchema — foreign zod instance description recove const { jsonSchema: _drop, ...stdNoJson } = real['~standard'] as unknown as Record; void _drop; Object.defineProperty(real, '~standard', { value: { ...stdNoJson, vendor: 'zod' }, configurable: true }); + // Shadow the field's `.description` getter with a value that DIFFERS from what the + // converter emits (the converter reads the registry, not this own-property), so the + // assertion can actually distinguish "preserved" from "overwritten with the same text". + Object.defineProperty(real.shape.a, 'description', { get: () => 'from recovery walk', configurable: true }); const result = standardSchemaToJsonSchema(real as unknown as SchemaArg); expect(props(result).a?.description).toBe('from converter'); diff --git a/packages/server/package.json b/packages/server/package.json index d7bce70fa4..c5d3334de5 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -112,6 +112,7 @@ "tsx": "catalog:devTools", "typescript": "catalog:devTools", "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" + "vitest": "catalog:devTools", + "zod-v40": "npm:zod@4.0.17" } } diff --git a/packages/server/test/server/mcp.foreignZodDescriptions.test.ts b/packages/server/test/server/mcp.foreignZodDescriptions.test.ts new file mode 100644 index 0000000000..42a2a6445d --- /dev/null +++ b/packages/server/test/server/mcp.foreignZodDescriptions.test.ts @@ -0,0 +1,93 @@ +import type { JSONRPCMessage, StandardSchemaWithJSON } from '@modelcontextprotocol/core'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as zOld from 'zod-v40'; + +import { McpServer } from '../../src/index.js'; + +// `zod-v40` is an npm alias for zod@4.0.x: a real second zod instance that implements +// StandardSchemaV1 but not `~standard.jsonSchema` (added in 4.2). An application that +// registers tools with schemas from its own zod 4.0/4.1 (or the zod@3.25.x `zod/v4` +// subpath) hits the bundled-converter fallback when the tool list is served. This test +// pins the end-to-end behavior a connected peer observes: the advertised tool schema +// retains `.describe()` metadata, and validated tool calls still round-trip. + +type ProfileInput = { name: string; address: { street: string } }; + +describe('tools/list over a transport with a foreign-zod inputSchema', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('advertises .describe() descriptions and validates calls end-to-end', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const server = new McpServer({ name: 't', version: '1.0.0' }); + let received: unknown; + server.registerTool( + 'create-profile', + { + description: 'creates a profile', + inputSchema: zOld + .object({ + name: zOld.string().describe('the display name'), + address: zOld.object({ street: zOld.string().describe('street and house number') }).describe('postal address') + }) + .describe('profile input') as unknown as StandardSchemaWithJSON + }, + async args => { + received = args; + return { content: [{ type: 'text' as const, text: 'ok' }] }; + } + ); + + const [client, srv] = InMemoryTransport.createLinkedPair(); + await server.connect(srv); + await client.start(); + + const responses: JSONRPCMessage[] = []; + client.onmessage = m => responses.push(m); + + await client.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'c', version: '1.0.0' } + } + } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', method: 'notifications/initialized' } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} } as JSONRPCMessage); + + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true)); + + const list = responses.find(r => 'id' in r && r.id === 2) as { + result?: { tools: Array<{ name: string; inputSchema: Record }> }; + }; + const tool = list.result?.tools.find(t => t.name === 'create-profile'); + expect(tool).toBeDefined(); + const schema = tool?.inputSchema as { + description?: string; + properties?: Record }>; + }; + // What the connected peer actually sees: descriptions survive the fallback conversion. + expect(schema.description).toBe('profile input'); + expect(schema.properties?.name?.description).toBe('the display name'); + expect(schema.properties?.address?.description).toBe('postal address'); + expect(schema.properties?.address?.properties?.street?.description).toBe('street and house number'); + + // The foreign schema also still validates calls (cross-instance `~standard.validate`). + await client.send({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'create-profile', arguments: { name: 'Ada', address: { street: 'Main St 1' } } } + } as JSONRPCMessage); + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 3)).toBe(true)); + expect(received).toEqual({ name: 'Ada', address: { street: 'Main St 1' } }); + + await server.close(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09ae330c9e..410640d13b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1010,6 +1010,9 @@ importers: vitest: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + zod-v40: + specifier: npm:zod@4.0.17 + version: zod@4.0.17 packages/server-legacy: dependencies: From 28abf10ecaafcef2aecf09738a41210b85496ff3 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 15:22:15 +0000 Subject: [PATCH 4/4] Recover descriptions for schema instances reused at multiple positions Cycle detection in the foreign-description walk was keyed on the schema instance across the whole traversal, so a described schema reused at several positions (sibling properties, nested objects) was only recovered at the first occurrence and silently skipped afterwards, even though the converter emits a distinct JSON Schema node per occurrence. Scope the guard to the current recursion path instead; the existing depth cap remains the backstop for runaway graphs. --- packages/core/src/util/standardSchema.ts | 26 ++++--- ...ndardSchema.zodForeignDescriptions.test.ts | 71 +++++++++++++++++++ .../server/mcp.foreignZodDescriptions.test.ts | 11 ++- 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index d22119c096..b717afb3d6 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -246,16 +246,22 @@ function foreignElement(node: unknown): unknown { * getters still work, so walk the schema alongside the converted JSON Schema and fill * in any descriptions the converter missed. Existing descriptions are never overwritten. */ -function recoverForeignDescriptions( - schema: unknown, - jsonSchema: Record, - visited = new WeakSet(), - depth = 0 -): void { +function recoverForeignDescriptions(schema: unknown, jsonSchema: Record, path = new WeakSet(), depth = 0): void { if (depth > 16 || schema == null || typeof schema !== 'object') return; - if (visited.has(schema)) return; - visited.add(schema); + // Cycle detection is scoped to the current recursion path: a schema instance that is + // *reused* at several positions (sibling properties, nested objects) is converted to a + // distinct JSON Schema node per occurrence and must be recovered at every one of them. + // Only a true ancestor cycle stops the walk; the depth cap bounds everything else. + if (path.has(schema)) return; + path.add(schema); + try { + recoverNode(schema, jsonSchema, path, depth); + } finally { + path.delete(schema); + } +} +function recoverNode(schema: object, jsonSchema: Record, path: WeakSet, depth: number): void { if (jsonSchema.description === undefined) { const description = readForeignDescriptionDeep(schema); if (description !== undefined) jsonSchema.description = description; @@ -268,7 +274,7 @@ function recoverForeignDescriptions( for (const [key, fieldSchema] of Object.entries(shape)) { const fieldJson = (properties as Record)[key]; if (fieldJson != null && typeof fieldJson === 'object') { - recoverForeignDescriptions(fieldSchema, fieldJson as Record, visited, depth + 1); + recoverForeignDescriptions(fieldSchema, fieldJson as Record, path, depth + 1); } } } @@ -278,7 +284,7 @@ function recoverForeignDescriptions( if (items != null && typeof items === 'object' && !Array.isArray(items)) { const element = foreignElement(schema); if (element != null) { - recoverForeignDescriptions(element, items as Record, visited, depth + 1); + recoverForeignDescriptions(element, items as Record, path, depth + 1); } } } diff --git a/packages/core/test/util/standardSchema.zodForeignDescriptions.test.ts b/packages/core/test/util/standardSchema.zodForeignDescriptions.test.ts index 769bb902c1..f1ea7a4fa6 100644 --- a/packages/core/test/util/standardSchema.zodForeignDescriptions.test.ts +++ b/packages/core/test/util/standardSchema.zodForeignDescriptions.test.ts @@ -84,6 +84,77 @@ describe('standardSchemaToJsonSchema — foreign zod instance description recove expect(() => standardSchemaToJsonSchema(schema as unknown as SchemaArg)).not.toThrow(); }); + it('recovers descriptions for a schema instance reused at multiple positions', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + // One schema instance referenced from several places: the converter inlines a + // distinct JSON Schema node per occurrence, so every occurrence needs recovery, + // not just the first one the walk happens to visit. + const tag = zOld.string().describe('a tag'); + const schema = zOld.object({ + primary: tag, + secondary: tag, + nested: zOld.object({ inner: tag }).describe('nested holder') + }); + + const result = standardSchemaToJsonSchema(schema as unknown as SchemaArg); + + expect(props(result).primary?.description).toBe('a tag'); + expect(props(result).secondary?.description).toBe('a tag'); + const nested = props(result).nested as { description?: string; properties?: Record }; + expect(nested.description).toBe('nested holder'); + expect(nested.properties?.inner?.description).toBe('a tag'); + }); + + it('recovers descriptions for a reused object schema and its children', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const address = zOld.object({ street: zOld.string().describe('street and number') }).describe('postal address'); + const schema = zOld.object({ home: address, work: address }); + + const result = standardSchemaToJsonSchema(schema as unknown as SchemaArg); + + for (const key of ['home', 'work'] as const) { + const node = props(result)[key] as { description?: string; properties?: Record }; + expect(node.description).toBe('postal address'); + expect(node.properties?.street?.description).toBe('street and number'); + } + }); + + it('terminates on cyclic schema graphs', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + // The walk duck-types foreign schemas (.description / .shape), so a self-referential + // graph is representable regardless of zod version. Recovery must stop at the cycle + // along the current path (with the depth cap as backstop), not recurse forever. + const cyclicSchema: { description: string; shape: Record } = { + description: 'a node', + shape: {} + }; + cyclicSchema.shape.self = cyclicSchema; + const cyclicJson: Record = { type: 'object', properties: {} }; + (cyclicJson.properties as Record).self = cyclicJson; + + // The bundled converter never emits cyclic output, so substitute it for this test + // to hand the recovery walk a worst-case graph on both sides. + vi.resetModules(); + vi.doMock('zod/v4', async importOriginal => { + const real = await importOriginal(); + return { ...real, toJSONSchema: () => cyclicJson }; + }); + try { + const { standardSchemaToJsonSchema: convert } = await import('../../src/util/standardSchema.js'); + const fake = { + '~standard': { version: 1, vendor: 'zod', validate: (value: unknown) => ({ value }) }, + _zod: {}, + description: cyclicSchema.description, + shape: cyclicSchema.shape + }; + const result = convert(fake as unknown as SchemaArg) as Record; + expect(result.description).toBe('a node'); + } finally { + vi.doUnmock('zod/v4'); + vi.resetModules(); + } + }); + it('zod 3 schemas still get the clear upgrade error', () => { const zod3ish = { _def: {}, '~standard': { version: 1, vendor: 'zod', validate: () => ({ value: {} }) } }; expect(() => standardSchemaToJsonSchema(zod3ish as unknown as SchemaArg)).toThrow(/zod 3/); diff --git a/packages/server/test/server/mcp.foreignZodDescriptions.test.ts b/packages/server/test/server/mcp.foreignZodDescriptions.test.ts index 42a2a6445d..7db38ae884 100644 --- a/packages/server/test/server/mcp.foreignZodDescriptions.test.ts +++ b/packages/server/test/server/mcp.foreignZodDescriptions.test.ts @@ -12,7 +12,7 @@ import { McpServer } from '../../src/index.js'; // pins the end-to-end behavior a connected peer observes: the advertised tool schema // retains `.describe()` metadata, and validated tool calls still round-trip. -type ProfileInput = { name: string; address: { street: string } }; +type ProfileInput = { name: string; address: { street: string }; primaryLabel?: string; secondaryLabel?: string }; describe('tools/list over a transport with a foreign-zod inputSchema', () => { afterEach(() => { @@ -23,6 +23,7 @@ describe('tools/list over a transport with a foreign-zod inputSchema', () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); const server = new McpServer({ name: 't', version: '1.0.0' }); + const label = zOld.string().describe('a short label').optional(); let received: unknown; server.registerTool( 'create-profile', @@ -31,7 +32,11 @@ describe('tools/list over a transport with a foreign-zod inputSchema', () => { inputSchema: zOld .object({ name: zOld.string().describe('the display name'), - address: zOld.object({ street: zOld.string().describe('street and house number') }).describe('postal address') + address: zOld.object({ street: zOld.string().describe('street and house number') }).describe('postal address'), + // One schema instance reused at two positions: both occurrences + // must carry the description in the advertised schema. + primaryLabel: label, + secondaryLabel: label }) .describe('profile input') as unknown as StandardSchemaWithJSON }, @@ -77,6 +82,8 @@ describe('tools/list over a transport with a foreign-zod inputSchema', () => { expect(schema.properties?.name?.description).toBe('the display name'); expect(schema.properties?.address?.description).toBe('postal address'); expect(schema.properties?.address?.properties?.street?.description).toBe('street and house number'); + expect(schema.properties?.primaryLabel?.description).toBe('a short label'); + expect(schema.properties?.secondaryLabel?.description).toBe('a short label'); // The foreign schema also still validates calls (cross-instance `~standard.validate`). await client.send({