fix(server): handle ZodEffects in normalizeObjectSchema and getObjectShape#1865
fix(server): handle ZodEffects in normalizeObjectSchema and getObjectShape#1865pch007 wants to merge 1 commit intomodelcontextprotocol:v1.xfrom
Conversation
…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.
🦋 Changeset detectedLatest 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 |
commit: |
|
@claude review |
There was a problem hiding this comment.
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.
| // 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; |
There was a problem hiding this comment.
🔴 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
- Define:
const s = z.object({ x: z.string() }).transform(v => v).transform(v => v) - Zod v4 internal structure:
s._zod.def = { type: 'pipe', in: { _zod: { def: { type: 'pipe', in: <ZodObject>, ... } } }, ... } normalizeObjectSchema(s): enters the v4 branch,def.type === 'pipe'→ true,def.in→ inner pipe.innerDef.type === 'pipe'→ the guardinnerDef.type === 'object'is false,innerDef.shapeisundefined→ returnsundefined.getObjectShape(s):rawShape = v4Schema._zod?.def?.shape→undefined(pipe has no shape). Theninner = def.in= inner pipe;inner._zod?.def?.shape→undefined→ returnsundefined.tools/listemitsEMPTY_OBJECT_JSON_SCHEMAfor 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.
travisbreaks
left a comment
There was a problem hiding this comment.
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.
Summary
Schemas wrapped with
.superRefine(),.refine(), or.transform()become ZodEffects (v3) or pipe types (v4) that lack.shape, causingnormalizeObjectSchema()to returnundefinedandtools/listto fall back toEMPTY_OBJECT_JSON_SCHEMA.Changes
unwrapZodEffects()helper that walks_def.schemachains (v3 ZodEffects) with a depth bound of 10 to prevent infinite loopsnormalizeObjectSchema()to detect v3 ZodEffects and v4 pipe types wrapping ZodObjects, returning the original schema sozodToJsonSchema()/toJSONSchema()can extract correct JSON SchemagetObjectShape()to extract shape through ZodEffects/pipe wrappers for both Zod versionsTests
Adds 4 new tests verifying
tools/listreturns 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/listresponse, making it impossible for clients to know what parameters the tool accepts.