Skip to content

tools/list returns empty inputSchema for ZodEffects-wrapped schemas (Zod .superRefine / .refine / .transform / .pipe) #2145

@produtoramaxvision

Description

@produtoramaxvision

Summary

When a tool's inputSchema is a Zod schema that has been transformed via .superRefine(), .refine(), .transform(), or .pipe() (Zod v3) — producing a ZodEffects (or ZodPipeline) wrapper around an underlying ZodObject — the tools/list JSON-RPC response emits an empty inputSchema: {} for that tool. Runtime validation of incoming tools/call arguments still works correctly (the SDK's safeParse handles ZodEffects), but client-side introspection (Cursor, IDE plugins, schema-driven UIs) loses all field metadata.

Tools whose inputSchema is a plain ZodObject are unaffected and emit a correct JSON Schema.

Reproduction

Minimal repro (Node 20+, TypeScript or JS):

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';

// Plain ZodObject — emits proper JSON Schema in tools/list
const PlainSchema = z.object({
  prompt: z.string().min(1),
  count: z.number().int().positive().default(1),
});

// ZodEffects (superRefine over the same shape) — emits {} in tools/list
const EffectsSchema = PlainSchema.superRefine((v, ctx) => {
  if (v.count > 100) {
    ctx.addIssue({ code: 'custom', path: ['count'], message: 'cap exceeded' });
  }
});

const server = new McpServer({ name: 'demo', version: '0.0.1' });

server.registerTool('plain_tool', {
  title: 'Plain',
  description: 'Plain ZodObject',
  inputSchema: PlainSchema, // ✅ tools/list inputSchema has properties
}, async () => ({ content: [{ type: 'text', text: 'ok' }] }));

server.registerTool('effects_tool', {
  title: 'Effects',
  description: 'ZodEffects (superRefine)',
  inputSchema: EffectsSchema, // ❌ tools/list inputSchema is `{}`
}, async () => ({ content: [{ type: 'text', text: 'ok' }] }));

After connecting any MCP client and calling tools/list, the response shows:

{
  "tools": [
    {
      "name": "plain_tool",
      "inputSchema": {
        "type": "object",
        "properties": {
          "prompt": { "type": "string", "minLength": 1 },
          "count": { "type": "integer", "exclusiveMinimum": 0, "default": 1 }
        },
        "required": ["prompt"]
      }
    },
    {
      "name": "effects_tool",
      "inputSchema": {}      // ← empty
    }
  ]
}

Same problem reproduces with .refine(), .transform(), and .pipe() (any wrapper that produces ZodEffects or ZodPipeline).

Expected behavior

tools/list should emit the JSON Schema of the underlying object shape regardless of whether the schema has been wrapped in ZodEffects / ZodPipeline. Cross-field validation logic (the superRefine/refine body) does not need to be serialized — its absence from the JSON Schema is expected — but the field-level metadata (properties, types, defaults, required) should still be exposed.

Actual behavior

tools/list returns inputSchema: {} for ZodEffects-wrapped schemas, leaving clients without field metadata.

Root cause

In src/server/zod-compat.tsnormalizeObjectSchema(schema):

// (Zod v3 branch)
const v3Schema = schema;
if (v3Schema.shape !== undefined) {
  return schema;
}
return undefined; // ← falls through here for ZodEffects

ZodEffects (Zod v3) does not expose .shape directly — it lives on schema._def.schema.shape. The same applies to ZodPipeline (._def.in / ._def.out), ZodOptional / ZodNullable / ZodReadonly / ZodBranded / ZodDefault (._def.innerType).

When normalizeObjectSchema returns undefined, the caller in src/server/mcp.ts falls back to emitting inputSchema: {} (see lines 79, 175 in dist/cjs/server/mcp.js).

Suggested fix

Add an unwrap step at the top of normalizeObjectSchema that peels off ZodEffects, ZodPipeline, ZodOptional, ZodNullable, ZodReadonly, ZodBranded, and ZodDefault until either a ZodObject is reached or no further unwrapping is possible. Pseudo-code:

function unwrapToObject(schema: unknown): unknown {
  if (!schema || typeof schema !== 'object') return schema;
  const def = (schema as { _def?: { typeName?: string; schema?: unknown; in?: unknown; innerType?: unknown } })._def;
  if (!def) return schema;

  // Zod v3 ZodEffects: ._def.schema is the wrapped schema
  if (def.typeName === 'ZodEffects' && def.schema) {
    return unwrapToObject(def.schema);
  }
  // ZodPipeline: ._def.in is the input-side schema
  if (def.typeName === 'ZodPipeline' && def.in) {
    return unwrapToObject(def.in);
  }
  // ZodOptional / ZodNullable / ZodReadonly / ZodBranded / ZodDefault: ._def.innerType
  if (def.innerType) {
    return unwrapToObject(def.innerType);
  }
  return schema;
}

export function normalizeObjectSchema(schema: AnySchemaCompat | undefined) {
  if (!schema) return undefined;
  const unwrapped = unwrapToObject(schema);
  // ... existing raw-shape detection and v3/v4 object checks operate on `unwrapped` ...
}

The equivalent for Zod v4 Mini would inspect _zod.def.type === 'pipe' | 'transform' | 'refine' | 'optional' | 'nullable' | ... and chain through _zod.def.in / _zod.def.innerType.

This preserves the documented behavior (object schemas pass through), fixes the silent fallback to {}, and does not require any caller changes.

Environment

  • @modelcontextprotocol/sdk 1.29.0
  • zod 3.25.76
  • Node.js 25.5.0
  • Windows 11 (and reproduced on Ubuntu 22.04 in CI matrix)
  • Affected paths in installed SDK: dist/cjs/server/zod-compat.js (lines ~110-156), dist/cjs/server/mcp.js (lines 79, 92, 175, 203, 432)

Workaround currently in use (production plugin)

We split each affected schema in two:

  • _FooBase = z.object({...}) (ZodObject — registered as MCP inputSchema for proper introspection)
  • FooInput = _FooBase.superRefine(...) (ZodEffects — kept as a separate validationSchema and re-parsed inside the request handler before delegating to the service layer)

This restores client-side hints and keeps cross-field validation server-side, at the cost of one extra .parse() per request and a custom validationSchema?: ZodTypeAny field on our tool registry. Happy to upstream a similar primitive if useful, but the cleanest fix is on the SDK side as described above.

Impact

Approximately 50% of complex tools (those with any cross-field rule via .superRefine / .refine) are affected. For our use case (multimedia generation plugin with 22 tools), 10 tools lost client-side metadata until we applied the workaround.

Happy to open a PR with the unwrap helper + tests if you'd like — let me know if you'd prefer a v3-only fix, a unified v3+v4 fix, or different unwrap semantics.

Thank you for the SDK!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions