Skip to content

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

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

    P2Moderate issues affecting some users, edge cases, potentially valuable featurebugSomething isn't workingfix proposedBot has a verified fix diff in the commentready for workEnough information for someone to start working on

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions