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/zod-fallback-description-recovery.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,34 @@ This applies to:
| `SchemaInput<T>` | `StandardSchemaWithJSON.InferInput<T>` |
| `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<T extends z.ZodType>(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()`,
Expand Down
13 changes: 7 additions & 6 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,27 +77,28 @@
}
},
"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",
"@types/eventsource": "catalog:devTools",
"@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",
"prettier": "catalog:devTools",
"tsx": "catalog:devTools",
"typescript": "catalog:devTools",
"typescript-eslint": "catalog:devTools",
"vitest": "catalog:devTools"
"vitest": "catalog:devTools",
"zod-v40": "npm:zod@4.0.17"
}
}
132 changes: 130 additions & 2 deletions packages/core/src/util/standardSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,131 @@

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<string, string | undefined> } }).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<string, unknown> | 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<string, unknown>;
} 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<string, unknown>, path = new WeakSet<object>(), depth = 0): void {
if (depth > 16 || schema == null || typeof schema !== 'object') return;
// 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<string, unknown>, path: WeakSet<object>, depth: number): void {
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<string, unknown>)[key];
if (fieldJson != null && typeof fieldJson === 'object') {
recoverForeignDescriptions(fieldSchema, fieldJson as Record<string, unknown>, path, 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<string, unknown>, path, depth + 1);
}
}
}

Check warning on line 290 in packages/core/src/util/standardSchema.ts

View check run for this annotation

Claude / Claude Code Review

Recovery walk skips anyOf/oneOf branches, losing nested descriptions under .nullable()/union object fields

The recovery walk in `recoverNode` only descends through `properties` (paired with the foreign `.shape`) and `items` (paired with `.element`), never into `anyOf`/`oneOf` branches — but the bundled `z.toJSONSchema()` emits `.nullable()` fields, `z.union()`, and discriminated unions as `{ anyOf: [...] }` nodes with no top-level `properties`/`items`, so descriptions nested inside a nullable or union object field (e.g. the `street` description in `z.object({ street: z.string().describe('street and n
Comment on lines +264 to +290
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The recovery walk in recoverNode only descends through properties (paired with the foreign .shape) and items (paired with .element), never into anyOf/oneOf branches — but the bundled z.toJSONSchema() emits .nullable() fields, z.union(), and discriminated unions as { anyOf: [...] } nodes with no top-level properties/items, so descriptions nested inside a nullable or union object field (e.g. the street description in z.object({ street: z.string().describe('street and number') }).describe('postal address').nullable()) are silently dropped from the advertised tools/list schema. Either descend into anyOf/oneOf entries (pairing each non-null branch with the unwrapped inner schema) or narrow the migration-doc claim, which currently lists .nullable() among the covered cases.

Extended reasoning...

What the bug is. recoverNode (packages/core/src/util/standardSchema.ts:264-290) walks the foreign zod schema alongside the converted JSON Schema and fills in missing description fields. It descends through exactly two JSON Schema keys: jsonSchema.properties (paired with the foreign .shape) and jsonSchema.items (paired with .element). There is no handling of anyOf or oneOf anywhere in the walk. The SDK-bundled z.toJSONSchema() (zod 4, target draft-2020-12) represents .nullable() fields as { anyOf: [ <inner schema>, { type: 'null' } ] }, and z.union([...]) / discriminated unions as anyOf/oneOf arrays — none of these nodes carry top-level properties or items, so the walk stops there and everything nested below is left without its description.

Step-by-step proof. Take a foreign-zod (4.0/4.1 or zod@3.25.x zod/v4) schema:

const schema = zOld.object({
    home: zOld.object({ street: zOld.string().describe('street and number') })
        .describe('postal address')
        .nullable()
});
  1. The bundled converter cannot see the foreign registry, so the raw output has no descriptions anywhere. properties.home is { anyOf: [ { type: 'object', properties: { street: { type: 'string' } } }, { type: 'null' } ] }.
  2. recoverForeignDescriptions reaches properties.home paired with the foreign ZodNullable. readForeignDescriptionDeep unwraps innerType and recovers 'postal address' onto the anyOf node — so the wrapper-level description is recovered (and the docs' .nullable() wrapper claim is backed for that level).
  3. recoverNode then looks for jsonSchema.properties and jsonSchema.items on the anyOf node. Neither exists, so the walk returns. It never visits anyOf[0].properties.street.
  4. Result: 'street and number' is missing from the advertised tools/list schema, while the identical field without .nullable() keeps it (the existing test for address.street covers that path).

The same applies to every object member of a z.union([...]) or z.discriminatedUnion(...) field, which are common in tool input schemas.

Why existing code/tests don't prevent it. foreignShape/foreignElement do unwrap .nullable() on the zod side, but the pairing logic on the JSON Schema side requires a properties/items key on the same node — which the converter places one level down inside anyOf[0]. The new test files (standardSchema.zodForeignDescriptions.test.ts, mcp.foreignZodDescriptions.test.ts) exercise nested objects, arrays, optional wrappers, and reused instances, but no .nullable() object field or union, so nothing pins this path.

Impact. This is the same silent-metadata-loss failure mode the PR sets out to fix, just one level deeper: nested field documentation disappears from the schema tool-calling models see, with no error or warning. It also creates a doc/implementation mismatch within this diff: docs/migration.md and the migration-SKILL matrix added here say descriptions are recovered for 'top level, object properties, array elements, and .optional()/.nullable()/.default() wrappers' — a reader with a nullable object field would naturally expect its property descriptions to survive, and they don't. To be fair, the recovery is explicitly billed as best-effort, conversion itself is unaffected, and pre-PR behavior lost all descriptions, so this only degrades an improvement rather than regressing anything — hence non-blocking.

How to fix. In recoverNode, when jsonSchema.anyOf (or oneOf) is an array, pair each non-{ type: 'null' } entry with the unwrapped foreign schema (unwrapForeignSchema already walks _zod.def.innerType; for true unions, the foreign _zod.def.options array can be zipped with the branches) and recurse with depth + 1. The existing path-scoped cycle guard and depth cap already bound the extra recursion. Alternatively, narrow the doc parenthetical to make clear that content nested inside nullable/union fields is not recovered.


/**
* Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema.
*
Expand All @@ -190,14 +315,17 @@
'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<string, unknown>;
recoverForeignDescriptions(schema, result);
} else {
throw new Error(
`Schema library "${std.vendor}" does not implement StandardJSONSchemaV1 (\`~standard.jsonSchema\`). ` +
Expand Down
Loading
Loading