diff --git a/.changeset/fix-zod-effects-normalize.md b/.changeset/fix-zod-effects-normalize.md new file mode 100644 index 000000000..2e564ece8 --- /dev/null +++ b/.changeset/fix-zod-effects-normalize.md @@ -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. diff --git a/src/server/zod-compat.ts b/src/server/zod-compat.ts index d95ee7908..8594489db 100644 --- a/src/server/zod-compat.ts +++ b/src/server/zod-compat.ts @@ -23,6 +23,7 @@ export interface ZodV3Internal { values?: unknown[]; shape?: Record | (() => Record); description?: string; + schema?: AnySchema; // present on ZodEffects (.refine/.superRefine/.transform) }; shape?: Record | (() => Record); value?: unknown; @@ -35,6 +36,7 @@ export interface ZodV4Internal { value?: unknown; values?: unknown[]; shape?: Record | (() => Record); + in?: AnySchema; // present on pipe types (from .transform()) }; }; value?: unknown; @@ -103,6 +105,26 @@ export async function safeParseAsync( return result as { success: true; data: SchemaOutput } | { 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 | undefined { if (!schema) return undefined; @@ -113,9 +135,24 @@ export function getObjectShape(schema: AnyObjectSchema | undefined): Record { } ]); }); + + 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()', () => {