modelcontextprotocol / modelcontextprotocol/typescript-sdk
tools/list returns empty inputSchema for ZodEffects-wrapped schemas (Zod .superRefine / .refine / .transform / .pipe)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 13.4k
- Forks
- 2.2k
- Avg merge
- 3d 15h
- Merged PRs (30d)
- 4
Description
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.ts → normalizeObjectSchema(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/sdk1.29.0zod3.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 MCPinputSchemafor proper introspection)FooInput = _FooBase.superRefine(...)(ZodEffects — kept as a separatevalidationSchemaand 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!
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/server/zod-compat.ts at normalizeObjectSchema and trace the inputSchema fallback in src/server/mcp.ts. Reproduce the issue with the supplied ZodEffects example and tools/list, then add coverage for the named wrappers and both supported Zod versions. Done means wrapped object schemas expose their properties, types, defaults, and required fields instead of returning {}.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 65/100