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
6 changes: 6 additions & 0 deletions .changeset/fix-zod-effects-normalize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/sdk': patch
---

Handle ZodEffects wrappers (`.superRefine()`, `.refine()`, `.transform()`) in `normalizeObjectSchema()` and `getObjectShape()`. Previously, schemas wrapped with these methods would fall back to `EMPTY_OBJECT_JSON_SCHEMA` in `tools/list` responses because `normalizeObjectSchema()`
only checked for `.shape` (v3) or `_zod.def.shape` (v4), which ZodEffects/pipe types lack. The fix walks through the wrapper chain to find the inner ZodObject, ensuring correct JSON Schema generation for tool listings.
63 changes: 63 additions & 0 deletions src/server/zod-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
values?: unknown[];
shape?: Record<string, AnySchema> | (() => Record<string, AnySchema>);
description?: string;
schema?: AnySchema; // present on ZodEffects (.refine/.superRefine/.transform)
};
shape?: Record<string, AnySchema> | (() => Record<string, AnySchema>);
value?: unknown;
Expand All @@ -35,6 +36,7 @@
value?: unknown;
values?: unknown[];
shape?: Record<string, AnySchema> | (() => Record<string, AnySchema>);
in?: AnySchema; // present on pipe types (from .transform())
};
};
value?: unknown;
Expand Down Expand Up @@ -103,6 +105,26 @@
return result as { success: true; data: SchemaOutput<S> } | { success: false; error: unknown };
}

// --- ZodEffects unwrapping ---
/**
* Unwrap ZodEffects wrappers (.superRefine(), .refine(), .transform()) to
* find the inner schema. ZodEffects chains store the wrapped schema in
* `_def.schema`. This walks the chain up to `maxDepth` levels to prevent
* infinite loops on malformed schemas.
*
* Returns the innermost non-ZodEffects schema, or the original schema if
* it is not a ZodEffects.
*/
function unwrapZodEffects(schema: AnySchema, maxDepth = 10): AnySchema {
let current = schema;
for (let i = 0; i < maxDepth; i++) {
const v3 = current as unknown as ZodV3Internal;
if (v3._def?.typeName !== 'ZodEffects' || !v3._def.schema) break;
current = v3._def.schema;
}
return current;
}

// --- Shape extraction ---
export function getObjectShape(schema: AnyObjectSchema | undefined): Record<string, AnySchema> | undefined {
if (!schema) return undefined;
Expand All @@ -113,12 +135,27 @@
if (isZ4Schema(schema)) {
const v4Schema = schema as unknown as ZodV4Internal;
rawShape = v4Schema._zod?.def?.shape;

// 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;

Check failure on line 158 in src/server/zod-compat.ts

View check run for this annotation

Claude / Claude Code Review

v4 pipe traversal only 1 level deep in getObjectShape and normalizeObjectSchema

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.
Comment on lines +139 to 158
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.


if (typeof rawShape === 'function') {
try {
Expand Down Expand Up @@ -177,12 +214,38 @@
if (def && (def.type === 'object' || def.shape !== undefined)) {
return schema as AnyObjectSchema;
}

// Check if it's a v4 pipe type (from .transform()) wrapping an object.
// In Zod v4, .transform() creates a pipe with def.in pointing to the
// input schema. Walk through pipes to find the inner object schema.
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;
}
}
} else {
// Check if it's a v3 object
const v3Schema = schema as unknown as ZodV3Internal;
if (v3Schema.shape !== undefined) {
return schema as AnyObjectSchema;
}

// Check if it's a v3 ZodEffects wrapping an object schema.
// ZodEffects are created by .superRefine(), .refine(), .transform(), etc.
// They lack .shape but have _def.schema pointing to the inner schema.
// Walk the chain to find the inner ZodObject.
const inner = unwrapZodEffects(schema as AnySchema);
if (inner !== schema) {
const innerV3 = inner as unknown as ZodV3Internal;
if (innerV3.shape !== undefined) {
// Return the original ZodEffects schema — zodToJsonSchema() and
// z4mini.toJSONSchema() can traverse ZodEffects to extract the
// correct JSON Schema from the full chain.
return schema as AnyObjectSchema;
}
}
}

return undefined;
Expand Down
177 changes: 177 additions & 0 deletions test/server/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5218,6 +5218,183 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
}
]);
});

test('should list correct JSON Schema properties for z.superRefine() schemas', async () => {
const server = new McpServer({
name: 'test',
version: '1.0.0'
});

const client = new Client({
name: 'test-client',
version: '1.0.0'
});

// z.superRefine() wraps a ZodObject in ZodEffects, which lacks .shape
const superRefineSchema = z
.object({
password: z.string(),
confirmPassword: z.string()
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Passwords do not match',
path: ['confirmPassword']
});
}
});

server.registerTool('superrefine-test', { inputSchema: superRefineSchema }, async args => {
return {
content: [{ type: 'text' as const, text: `Password set for ${args.password}` }]
};
});

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);

// Verify tools/list returns correct JSON Schema (not empty)
const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema);
expect(result.tools).toHaveLength(1);
expect(result.tools[0].inputSchema).toMatchObject({
type: 'object',
properties: {
password: { type: 'string' },
confirmPassword: { type: 'string' }
}
});

// Also verify the tool still works (parsing path)
const callResult = await client.callTool({
name: 'superrefine-test',
arguments: { password: 'secret', confirmPassword: 'secret' }
});
expect(callResult.content).toEqual([{ type: 'text', text: 'Password set for secret' }]);
});

test('should list correct JSON Schema properties for z.refine() schemas', async () => {
const server = new McpServer({
name: 'test',
version: '1.0.0'
});

const client = new Client({
name: 'test-client',
version: '1.0.0'
});

const refineSchema = z
.object({
min: z.number(),
max: z.number()
})
.refine(data => data.max > data.min, {
message: 'max must be greater than min'
});

server.registerTool('refine-test', { inputSchema: refineSchema }, async args => {
return {
content: [{ type: 'text' as const, text: `Range: ${args.min}-${args.max}` }]
};
});

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);

const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema);
expect(result.tools).toHaveLength(1);
expect(result.tools[0].inputSchema).toMatchObject({
type: 'object',
properties: {
min: { type: 'number' },
max: { type: 'number' }
}
});
});

test('should list correct JSON Schema for z.transform() schemas via tools/list', async () => {
const server = new McpServer({
name: 'test',
version: '1.0.0'
});

const client = new Client({
name: 'test-client',
version: '1.0.0'
});

const transformSchema = z
.object({
input: z.string()
})
.transform(data => ({ ...data, upper: data.input.toUpperCase() }));

server.registerTool('transform-list-test', { inputSchema: transformSchema }, async args => {
return {
content: [{ type: 'text' as const, text: args.upper }]
};
});

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);

const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema);
expect(result.tools).toHaveLength(1);
expect(result.tools[0].inputSchema).toMatchObject({
type: 'object',
properties: {
input: { type: 'string' }
}
});
});

test('should list correct JSON Schema for nested ZodEffects chains', async () => {
const server = new McpServer({
name: 'test',
version: '1.0.0'
});

const client = new Client({
name: 'test-client',
version: '1.0.0'
});

// Chain: ZodObject -> .superRefine() -> .transform() (nested ZodEffects)
const nestedSchema = z
.object({
value: z.string()
})
.superRefine((data, ctx) => {
if (data.value.length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Value required' });
}
})
.transform(data => ({ ...data, validated: true }));

server.registerTool('nested-effects', { inputSchema: nestedSchema }, async args => {
return {
content: [{ type: 'text' as const, text: `${args.value}: ${args.validated}` }]
};
});

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);

const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema);
expect(result.tools).toHaveLength(1);
expect(result.tools[0].inputSchema).toMatchObject({
type: 'object',
properties: {
value: { type: 'string' }
}
});
});
});

describe('resource()', () => {
Expand Down
Loading