feat(compat): registerTool/registerPrompt accept raw Zod shape, auto-wrap with z.object() - #1901
Conversation
🦋 Changeset detectedLatest commit: 617830b The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/server
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
182ec53 to
9b606ab
Compare
2972af1 to
a1bf55a
Compare
…wrap with z.object)
a1bf55a to
27e4ddf
Compare
|
@claude review |
|
@claude review |
…tputSchema raw-shape test
|
@claude review |
…spy; cover normalizeRawShapeSchema passthrough/undefined
688b282 to
1af9ed2
Compare
|
@claude review |
| function isZodV4Schema(v: unknown): v is z.ZodType { | ||
| // `_zod` is the v4 internal namespace property. Zod v3 schemas have `_def` | ||
| // and (since 3.24) `~standard.vendor === 'zod'`, but never `_zod`. We require | ||
| // v4 because the wrap path below uses v4's `z.object()`, which cannot consume | ||
| // v3 field schemas. | ||
| return typeof v === 'object' && v !== null && '_zod' in v; | ||
| } |
There was a problem hiding this comment.
🟡 nit: same guard-over-acceptance class as the ArkType (0152b26) and Zod-v3 (068ff56) cases — isZodV4Schema keys on '_zod' in v, which is also present on zod/mini schemas. { a: zm.string() } passes isZodRawShape, gets wrapped as classic z.object({...}), and on tools/list → std.jsonSchema.input(...) throws [toJSONSchema]: Non-representable type encountered: string (the wrapper has ~standard.jsonSchema, so #1895's working z.toJSONSchema fallback is bypassed — meaning auto-wrap introduces a crash that zm.object({...}) passed directly would not hit). TS rejects it (mini's types ≠ z.ZodType) so this only bites JS/as any callers — exactly the cohort the line-70 guard was added for. Consider '_zod' in v && '_def' in v (classic v4 has both; mini has only _zod; v3 has only _def) — also subsumes the v3 rejection.
Extended reasoning...
What the bug is
isZodV4Schema (zodCompat.ts:17) is the per-field predicate that gates whether a raw shape is handed to z.object(). It accepts anything with a _zod property — but zod/mini schemas also have _zod (they share @zod/core with classic v4). So { a: zm.string() } passes isZodRawShape, gets wrapped as classic z4.object({ a: zm.string() }), and the resulting object's ~standard.jsonSchema.input() throws on tools/list. This is structurally the same deferred-crash guard-over-acceptance class as the ArkType (0152b26), Zod-v3 (068ff56), bare-V1 (c75bc88), null, and non-plain-object cases already tightened in this PR.
The code path that triggers it
import * as zm from 'zod/mini';
server.registerTool('x', { inputSchema: { a: zm.string() } } as any, async ({ a }) => …);isZodRawShape({a: zm.string()}): not null/object ✓, not itself a StandardSchema ✓ (the record has no~standard), proto ===Object.prototype✓,Object.values→[zm.string()],isZodV4Schema(zm.string())→'_zod' in v→ true.normalizeRawShapeSchemareturnsz.object({a: zm.string()})— a classic v4ZodObject. Constructs without throwing (lazy).- Stored as
tool.inputSchema. tools/list→standardSchemaToJsonSchema(wrapped, 'input')→std.jsonSchemaexists (classic v4ZodObjecthas it) → callsstd.jsonSchema.input({target:'draft-2020-12'})→ throws[toJSONSchema]: Non-representable type encountered: string.
Notably, if the user instead passes zm.object({a: zm.string()}) directly (no auto-wrap), it works: mini objects have no ~standard.jsonSchema, so #1895's z.toJSONSchema() fallback fires and succeeds. So the auto-wrap introduces a crash for an input that the non-wrapped path handles.
Why nothing else catches it
The guard chain ends at '_zod' in v (zodCompat.ts:17), which both zod/v4 and zod/mini schemas satisfy. z.object() constructs lazily so no error at wrap time. Because the wrapper is classic-v4, it has ~standard.jsonSchema, so standardSchemaToJsonSchema (standardSchema.ts:158) takes the direct path and bypasses the working z.toJSONSchema fallback. The line-70 guard (!isStandardSchema(schema)) is on the fall-through path; mini fields take the isZodRawShape → true branch and never reach it.
Step-by-step proof (verified against zod@4.3.6 in the repo)
| Step | Expression | Result |
|---|---|---|
| 1 | '_zod' in zm.string() |
true (mini shares the v4 core) |
| 2 | '_def' in zm.string() |
false (classic-only) |
| 3 | zm.string() instanceof z.ZodType |
false |
| 4 | isZodV4Schema(zm.string()) |
true (via '_zod' in v) |
| 5 | isZodRawShape({a: zm.string()}) |
true |
| 6 | const w = z.object({a: zm.string()}) |
constructs OK, no throw |
| 7 | typeof w['~standard'].jsonSchema?.input |
'function' — fallback bypassed |
| 8 | w['~standard'].jsonSchema.input({target:'draft-2020-12'}) |
throws [toJSONSchema]: Non-representable type encountered: string |
| 9 | z.toJSONSchema(w, {io:'input'}) |
succeeds — confirms the bypassed fallback would have worked |
| 10 | zm.object({a: zm.string()})['~standard'].jsonSchema |
undefined — would hit working fallback |
Impact
Low — hence nit. TypeScript rejects it (zm.string() is not assignable to z.ZodType from zod/v4, so {a: zm.string()} doesn't satisfy Record<string, z.ZodType>), so this only bites JS / as any / @ts-ignore callers — but that is precisely the cohort the line-70 guard was added for per KKonstantinov's review (3146829297). zod/mini is also v4-only, so it has zero overlap with the v1-compat target audience this @deprecated shim exists for; mini users would naturally write zm.object({...}) (which works). The underlying ~standard.jsonSchema ≠ z.toJSONSchema discrepancy for mini-in-classic-wrapper is arguably a zod bug. Mentioning because it's the same class this PR has tightened five times already and the fix bundles trivially.
Fix
Tighten isZodV4Schema to require the classic v4 marker:
return typeof v === 'object' && v !== null && '_zod' in v && '_def' in v;Classic v4 has both _zod and _def; mini has only _zod; v3 has only _def — so this cleanly accepts classic-v4-only and also subsumes the existing v3 rejection. (Avoiding v instanceof z.ZodType since instanceof is fragile across dual zod installs.) With this, {a: zm.string()} falls through to the line-70 "must be a Standard Schema" TypeError at registration time instead of crashing on tools/list.
| /** | ||
| * Zod-specific helpers for the v1-compat raw-shape shorthand on | ||
| * `registerTool`/`registerPrompt`. Kept separate from `standardSchema.ts` so | ||
| * that file stays library-agnostic per the Standard Schema spec. | ||
| */ |
There was a problem hiding this comment.
🟡 nit: this file header says it's "Kept separate from standardSchema.ts so that file stays library-agnostic", and the reply on the earlier thread said "standardSchema.ts no longer imports zod" — both were true at 0febd83, but merge 9b7ee90 brought in #1895 (b256546), which re-added import * as z from 'zod/v4' to standardSchema.ts:9 for the z.toJSONSchema() fallback at line 178. Suggest softening to e.g. "Kept separate so standardSchema.ts stays focused on the Standard Schema spec interface", or moving the #1895 zod fallback into zodCompat.ts so the claim actually holds.
Extended reasoning...
What's wrong
The new file-level JSDoc at packages/core/src/util/zodCompat.ts:3-4 reads:
Kept separate from
standardSchema.tsso that file stays library-agnostic per the Standard Schema spec.
And the author's reply on inline thread 3147227355 (resolving KKonstantinov's "move these out into a zodCompat one" ask) stated "standardSchema.ts no longer imports zod." Both statements were accurate at commit 0febd83, when the split was made. But the subsequent merge commit 9b7ee90 brought b256546 (#1895) onto this branch, and #1895 re-introduced a zod import into standardSchema.ts for its zod-4.0–4.1 z.toJSONSchema() fallback. So at HEAD (617830b), the rationale clause in this PR's newly-added comment is factually incorrect — standardSchema.ts is not library-agnostic.
Step-by-step proof
packages/core/src/util/zodCompat.ts:1-5(newly added in this PR's diff):/** * Zod-specific helpers for the v1-compat raw-shape shorthand on * `registerTool`/`registerPrompt`. Kept separate from `standardSchema.ts` so * that file stays library-agnostic per the Standard Schema spec. */
git logshows 0febd83 (createszodCompat.tsand removes the zod import fromstandardSchema.ts) was committed before merge 9b7ee90.- Merge 9b7ee90 brought in b256546 (fix(core): fall back to z.toJSONSchema for zod schemas without ~standard.jsonSchema #1895), which adds the zod-4.0–4.1 fallback to
standardSchemaToJsonSchema. - At HEAD,
packages/core/src/util/standardSchema.ts:9readsimport * as z from 'zod/v4';and line 178 readsresult = z.toJSONSchema(schema as unknown as z.ZodType, { target: 'draft-2020-12', io }) .... - Therefore the claim "so that file stays library-agnostic" — written and shipped by this PR's diff — is contradicted by the same branch's
standardSchema.ts.
Why nothing catches it
There's no lint or test that cross-references prose comments against imports. The comment was correct when written; it became stale via a textual-clean merge (no conflict markers, since #1895 touched standardSchema.ts and this PR added a new file). The author's earlier resolved reply ("standardSchema.ts no longer imports zod") is on a closed thread, so nothing prompts a re-check after the merge.
Impact
Zero behavioral impact — this is an internal file-header comment, not user-facing JSDoc, changelog, or .d.ts. The organizational split itself (raw-shape compat helpers vs. Standard Schema spec utilities) remains perfectly sound; only the stated rationale for the split is wrong. Hence nit, non-blocking. The reason it's worth a one-line fix is that it sits at the top of a brand-new file added by this PR and will mislead the next contributor who reads it ("so I shouldn't add zod to standardSchema.ts" — but it's already there).
Suggested fix
Either soften the comment so it doesn't make a falsifiable claim:
/**
* Zod-specific helpers for the v1-compat raw-shape shorthand on
* `registerTool`/`registerPrompt`. Kept separate from `standardSchema.ts` so
* that file stays focused on the Standard Schema spec interface.
*/…or, if you actually want to deliver on the "library-agnostic" claim, move #1895's z.toJSONSchema() fallback (standardSchema.ts:158-178) into zodCompat.ts and call it from standardSchemaToJsonSchema — that would make both files match their stated purpose. The first option is the one-liner.
…nstead of closed .tool() variadic (C2/#1900)
…nstead of closed .tool() variadic (C2/#1900)
…nstead of closed .tool() variadic (C2/#1900)
Part of the v2 backwards-compatibility series — see reviewer guide.
v2 requires StandardSchema objects (e.g.
z.object({...})) forinputSchema. v1 accepted raw shapes{x: z.string()}. This auto-wraps raw shapesMotivation and Context
v2 requires StandardSchema objects (e.g.
z.object({...})) forinputSchema. v1 accepted raw shapes{x: z.string()}. This auto-wraps raw shapesv1 vs v2 pattern & evidence
v1 pattern:
`registerTool('x', {inputSchema: {a: z.string()}}, cb)`v2-native:
`registerTool('x', {inputSchema: z.object({a: z.string()})}, cb)`Evidence: ~70% of typical server migration LOC was wrapping shapes. Took multiple OSS repos to zero.
How Has This Been Tested?
v2-bc-integrationvalidation branchpnpm typecheck:all && pnpm lint:all && pnpm test:allgreenBreaking Changes
None — additive
@deprecatedshim.Types of changes
Checklist
Additional context
Stacks on: C1