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 |
v1 accepted any zod schema (AnySchema). v2 narrowed to StandardSchemaWithJSON, which requires ~standard.jsonSchema. completable() itself only needs validate (to type the callback's value param); JSON-Schema generation happens at the outer registerPrompt argsSchema level, not per-field. Widening to StandardSchemaV1 restores the v1 surface (any Standard Schema lib, zod >=3.24). Adds raw-shape registerPrompt test with a completable() field.
KKonstantinov
left a comment
There was a problem hiding this comment.
LGTM, small nits only.
- Both migration docs (the skill and general doc) state: "Raw object shapes are no longer accepted." This PR makes them accepted again. The changeset text is accurate, but the migration docs are not updated. Per REVIEW.md's "Documentation & Changesets" recurring catch, this is a flag.
…normalizeRawShapeSchema input; preserve optional in InferRawShape - Move isZodSchema/isZodRawShape/normalizeRawShapeSchema from standardSchema.ts to a new zodCompat.ts so standardSchema.ts is Standard-Schema-spec only. - normalizeRawShapeSchema now throws TypeError for inputs that are neither a raw shape nor a Standard Schema, instead of silently returning them. - InferRawShape now uses z.infer<z.ZodObject<S>> so .optional() fields produce ?: keys. - Changeset mentions the completable() constraint widening.
…malizeRawShapeSchema guard to require ~standard.jsonSchema
…ble error isZodSchema previously matched on `_def` / `~standard.vendor === 'zod'`, both of which Zod v3 schemas also satisfy. A v3 raw shape would pass isZodRawShape, get wrapped by v4's z.object(), and crash deep inside zod when listing or calling the tool. Now detect v4 via the `_zod` property (absent on v3), and add a dedicated runtime guard in normalizeRawShapeSchema that throws a clear TypeError when v3 fields are seen, telling the user to import from zod/v4 or wrap the shape themselves.
068ff56 to
a6b25ee
Compare
…ard v3-detection branch
There was a problem hiding this comment.
b5854c1 addresses both remaining nits (plain-object prototype check + null guard), and I have nothing further — leaving final sign-off to a human since this adds public overloads/types and widens the completable() constraint.
Extended reasoning...
Overview
This PR adds a v1-compat shim so registerTool/registerPrompt accept raw Zod shapes ({ field: z.string() }) and auto-wrap them with z.object(). It introduces packages/core/src/util/zodCompat.ts (isZodRawShape, normalizeRawShapeSchema), adds @deprecated overloads + new exported types (ZodRawShape, InferRawShape, LegacyToolCallback, LegacyPromptCallback) in mcp.ts, and widens the completable() generic constraint from StandardSchemaWithJSON to StandardSchemaV1. Tests live in zodCompat.test.ts and mcp.compat.test.ts.
Status of prior feedback
Every issue I raised across the review rounds has been addressed, most recently in b5854c1: isZodRawShape now requires a plain-object prototype (rejects [], Date, Map, etc., with tests), and the v3-detection branch in normalizeRawShapeSchema now null-guards before Object.values() (with a test asserting the helpful TypeError fires). The earlier rounds covered the empty-shape case, Zod-only narrowing, v3 detection, isStandardSchemaWithJSON guard, changeset wording, test-file split, outputSchema coverage, and InferRawShape optionality — all resolved. KKonstantinov's two asks (separate zodCompat module; runtime guard for invalid pass-through) are also applied.
Security risks
None identified. This is schema-handling glue for developer-supplied tool/prompt definitions; no auth, transport, or untrusted-input parsing changes.
Level of scrutiny
Moderate-to-high: it expands the public API surface of @modelcontextprotocol/server (new overloads and exported types), relaxes a public generic constraint on completable(), and is one of ~22 coordinated v2-bc PRs whose docs land separately in #1910. Per REVIEW.md ("Burden of proof is on addition", "Every new export is intentional"), API-surface additions warrant maintainer sign-off rather than bot approval.
Other factors
KKonstantinov already engaged on this PR and his asks were addressed; final approval should come from him or another maintainer. Migration-doc updates are intentionally deferred to #1910. The one remaining optional suggestion (re-exporting StandardSchemaV1 from core/public) was explicitly flagged non-blocking and is fine to skip.
… normalizeRawShapeSchema The merge from main brought in the zod 4.0-4.1 fallback in standardSchemaToJsonSchema, but normalizeRawShapeSchema's guard (tightened to isStandardSchemaWithJSON before that fallback existed on main) threw at registration time for exactly the schemas the fallback handles, making it unreachable from registerTool/registerPrompt. Relax the guard to isStandardSchema so any valid StandardSchema passes through; standardSchemaToJsonSchema already owns per-vendor handling for schemas without ~standard.jsonSchema (zod 4.0-4.1 fallback, zod 3 error, non-zod error). This keeps a single source of truth for what is convertible.
| 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)
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