Skip to content

fix(server): handle ZodEffects in normalizeObjectSchema and getObjectShape#1865

Open
pch007 wants to merge 1 commit intomodelcontextprotocol:v1.xfrom
pch007:fix/zod-effects-normalize-object-schema
Open

fix(server): handle ZodEffects in normalizeObjectSchema and getObjectShape#1865
pch007 wants to merge 1 commit intomodelcontextprotocol:v1.xfrom
pch007:fix/zod-effects-normalize-object-schema

Conversation

@pch007
Copy link
Copy Markdown

@pch007 pch007 commented Apr 8, 2026

Summary

Schemas wrapped with .superRefine(), .refine(), or .transform() become ZodEffects (v3) or pipe types (v4) that lack .shape, causing normalizeObjectSchema() to return undefined and tools/list to fall back to EMPTY_OBJECT_JSON_SCHEMA.

Changes

  • Adds unwrapZodEffects() helper that walks _def.schema chains (v3 ZodEffects) with a depth bound of 10 to prevent infinite loops
  • Updates normalizeObjectSchema() to detect v3 ZodEffects and v4 pipe types wrapping ZodObjects, returning the original schema so zodToJsonSchema() / toJSONSchema() can extract correct JSON Schema
  • Updates getObjectShape() to extract shape through ZodEffects/pipe wrappers for both Zod versions

Tests

Adds 4 new tests verifying tools/list returns correct JSON Schema properties for .superRefine(), .refine(), .transform(), and nested ZodEffects chains. All tests run against both Zod v3 and v4.

All 1587 existing tests pass. Lint + Prettier clean.

Impact

Fixes: tools registered with refined/transformed schemas now correctly advertise their input properties to MCP clients. Without this fix, any tool using Zod refinements loses its schema information in the tools/list response, making it impossible for clients to know what parameters the tool accepts.

…Shape

Schemas wrapped with `.superRefine()`, `.refine()`, or `.transform()`
become ZodEffects (v3) or pipe types (v4) that lack `.shape`, causing
`normalizeObjectSchema()` to return `undefined` and `tools/list` to
fall back to `EMPTY_OBJECT_JSON_SCHEMA`.

This fix:
- Adds `unwrapZodEffects()` helper that walks `_def.schema` chains
  (v3 ZodEffects) with a depth bound of 10 to prevent infinite loops
- Updates `normalizeObjectSchema()` to detect v3 ZodEffects and v4
  pipe types wrapping ZodObjects, returning the original schema so
  `zodToJsonSchema()` / `toJSONSchema()` can extract correct JSON Schema
- Updates `getObjectShape()` to extract shape through ZodEffects/pipe
  wrappers for both Zod versions

Adds 4 new tests verifying `tools/list` returns correct JSON Schema
properties for `.superRefine()`, `.refine()`, `.transform()`, and
nested ZodEffects chains. All tests run against both Zod v3 and v4.

Fixes: tools registered with refined/transformed schemas now correctly
advertise their input properties to MCP clients.
@pch007 pch007 requested a review from a team as a code owner April 8, 2026 23:29
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 8, 2026

🦋 Changeset detected

Latest commit: ac01b14

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 8, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@modelcontextprotocol/sdk@1865

commit: ac01b14

@felixweinberger
Copy link
Copy Markdown
Contributor

@claude review

Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger left a comment

Choose a reason for hiding this comment

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

Hi @pch007 thanks for submitting this - at a first glance this looks OK but wary of building additional complexity into a compatibility layer without strong justification.

Could you elaborate on whether you ran into this scenario in something you were building or is this purely speculative / examining the source somehow? I don't see an issue for this, so wondering how you stumbled on this.

Comment on lines +139 to 158
// If no shape found, check if it's a v4 pipe (from .transform())
if (!rawShape && v4Schema._zod?.def?.type === 'pipe' && v4Schema._zod?.def?.in) {
const inner = v4Schema._zod.def.in as unknown as ZodV4Internal;
rawShape = inner._zod?.def?.shape;
}
} else {
const v3Schema = schema as unknown as ZodV3Internal;
rawShape = v3Schema.shape;

// If no shape found, check if this is a ZodEffects wrapping a ZodObject
if (!rawShape) {
const inner = unwrapZodEffects(schema as AnySchema);
if (inner !== schema) {
const innerV3 = inner as unknown as ZodV3Internal;
rawShape = innerV3.shape;
}
}
}

if (!rawShape) return undefined;
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 v4 pipe traversal in both getObjectShape and normalizeObjectSchema only walks one level deep, so any Zod v4 schema with two or more chained .transform() calls (e.g. z.object({x: z.string()}).transform(fn1).transform(fn2)) will still fall back to EMPTY_OBJECT_JSON_SCHEMA in tools/list after this PR. This is asymmetric with the v3 path, which uses unwrapZodEffects() to walk up to 10 levels; the fix is to add an analogous loop for v4 that walks _zod.def.in chains.

Extended reasoning...

What the bug is and how it manifests

In Zod v4, each call to .transform() creates a ZodPipe whose _zod.def.in points to the previous schema. Chaining two transforms therefore produces a nested pipe structure: the outer pipe's def.in is another pipe, not a ZodObject. Both getObjectShape (lines 139-143) and normalizeObjectSchema (lines 221-227) only check one level into that chain, so they silently fail for any schema with two or more chained transforms in Zod v4.

The specific code path that triggers it

In getObjectShape, the fix is:

if (!rawShape && v4Schema._zod?.def?.type === 'pipe' && v4Schema._zod?.def?.in) {
    const inner = v4Schema._zod.def.in as unknown as ZodV4Internal;
    rawShape = inner._zod?.def?.shape;   // ← only one level
}

If def.in is itself a pipe (the double-transform case), inner._zod?.def?.shape is undefined because pipes have no .shape, so rawShape stays undefined and the function returns undefined.

In normalizeObjectSchema:

if (def?.type === 'pipe' && def.in) {
    const inner = def.in as unknown as ZodV4Internal;
    const innerDef = inner._zod?.def;
    if (innerDef && (innerDef.type === 'object' || innerDef.shape !== undefined)) {
        return schema as AnyObjectSchema;  // ← only succeeds if def.in IS the object
    }
}

For a double-transform schema, innerDef.type === 'pipe' (not 'object'), so the guard fails and normalizeObjectSchema returns undefined, causing tools/list to emit EMPTY_OBJECT_JSON_SCHEMA.

Why existing code doesn't prevent it

The PR adds a test titled 'should list correct JSON Schema for nested ZodEffects chains', but that test uses .superRefine().transform() — not .transform().transform(). In Zod v4, .superRefine() mutates the object schema in place (the type stays 'object'), so a superRefine+transform chain only creates a single-level pipe whose def.in IS the object. The test passes, but the double-transform gap is never exercised.

Impact

Any MCP tool registered with a Zod v4 schema that chains two or more .transform() calls — a valid and common pattern — will advertise an empty input schema to clients. Clients cannot know what parameters the tool accepts, breaking auto-completion, validation, and documentation generation.

Additionally, getObjectShape is called directly by promptArgumentsFromSchema (and the completions handler) without going through normalizeObjectSchema first, so prompt argument listings are also broken for such schemas.

Step-by-step proof

  1. Define: const s = z.object({ x: z.string() }).transform(v => v).transform(v => v)
  2. Zod v4 internal structure: s._zod.def = { type: 'pipe', in: { _zod: { def: { type: 'pipe', in: <ZodObject>, ... } } }, ... }
  3. normalizeObjectSchema(s): enters the v4 branch, def.type === 'pipe' → true, def.in → inner pipe. innerDef.type === 'pipe' → the guard innerDef.type === 'object' is false, innerDef.shape is undefined → returns undefined.
  4. getObjectShape(s): rawShape = v4Schema._zod?.def?.shapeundefined (pipe has no shape). Then inner = def.in = inner pipe; inner._zod?.def?.shapeundefined → returns undefined.
  5. tools/list emits EMPTY_OBJECT_JSON_SCHEMA for the tool.

How to fix it

Add a loop analogous to unwrapZodEffects that walks _zod.def.in chains for v4 pipes (up to a depth bound of 10), and use it in both getObjectShape and normalizeObjectSchema:

function unwrapV4Pipe(schema: ZodV4Internal, maxDepth = 10): ZodV4Internal {
    let cur = schema;
    for (let i = 0; i < maxDepth; i++) {
        if (cur._zod?.def?.type !== 'pipe' || !cur._zod.def.in) break;
        cur = cur._zod.def.in as unknown as ZodV4Internal;
    }
    return cur;
}

Then replace the single-level checks with a call to this helper.

Copy link
Copy Markdown

@travisbreaks travisbreaks left a comment

Choose a reason for hiding this comment

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

The problem is real and the approach is sound: .superRefine(), .refine(), and .transform() wrap the underlying ZodObject in ZodEffects, which lacks .shape. Without unwrapping, normalizeObjectSchema falls through to EMPTY_OBJECT_JSON_SCHEMA and tools/list returns a schema with no properties. Users hit this the moment they add validation refinements to their tool input schemas.

A few things worth considering:

The maxDepth = 10 guard in unwrapZodEffects is reasonable but the comment says "prevent infinite loops on malformed schemas." ZodEffects chains aren't circular by construction; this is more of a defensive ceiling than a loop guard. Might be clearer as a comment.

The v4 pipe unwrapping in normalizeObjectSchema only goes one level deep (checks def.in but doesn't recurse). If someone chains .transform().transform(), the outer pipe's in is another pipe, not a ZodObject. The v3 path handles this via unwrapZodEffects recursion but the v4 path doesn't have an equivalent. Worth a test case or a note about the limitation.

The 4 test cases are thorough: .superRefine(), .refine(), .transform(), and nested chains. All verify both tools/list schema output and actual tool execution. Good coverage.

The changeset says patch, which is correct since this fixes existing behavior without adding API surface.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants