Skip to content

feat(compat): registerTool/registerPrompt accept raw Zod shape, auto-wrap with z.object() - #1901

Merged
felixweinberger merged 16 commits into
mainfrom
fweinberger/v2-bc-register-rawshape
Apr 29, 2026
Merged

feat(compat): registerTool/registerPrompt accept raw Zod shape, auto-wrap with z.object()#1901
felixweinberger merged 16 commits into
mainfrom
fweinberger/v2-bc-register-rawshape

Conversation

@felixweinberger

@felixweinberger felixweinberger commented Apr 15, 2026

Copy link
Copy Markdown
Contributor

Part of the v2 backwards-compatibility series — see reviewer guide.

v2 requires StandardSchema objects (e.g. z.object({...})) for inputSchema. v1 accepted raw shapes {x: z.string()}. This auto-wraps raw shapes

Motivation and Context

v2 requires StandardSchema objects (e.g. z.object({...})) for inputSchema. v1 accepted raw shapes {x: z.string()}. This auto-wraps raw shapes

v1 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?

  • packages/server/test/server/mcp.compat.test.ts — 3 cases
  • Integration: validated bump-only against 5 OSS repos via the v2-bc-integration validation branch
  • pnpm typecheck:all && pnpm lint:all && pnpm test:all green

Breaking Changes

None — additive @deprecated shim.

Types of changes

  • New feature (non-breaking change which adds functionality)

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added or updated documentation as needed

Additional context

Stacks on: C1

@felixweinberger felixweinberger added the v2-bc v2 backwards-compatibility series label Apr 15, 2026
@changeset-bot

changeset-bot Bot commented Apr 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 617830b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/core Patch
@modelcontextprotocol/server Patch
@modelcontextprotocol/node Patch
@modelcontextprotocol/express Patch
@modelcontextprotocol/fastify Patch
@modelcontextprotocol/hono Patch

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

@felixweinberger felixweinberger added this to the v2.0.0-bc milestone Apr 15, 2026
@pkg-pr-new

pkg-pr-new Bot commented Apr 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@1901

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@1901

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@1901

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1901

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@1901

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@1901

commit: 617830b

@felixweinberger
felixweinberger force-pushed the fweinberger/v2-bc-register-rawshape branch from 182ec53 to 9b606ab Compare April 16, 2026 09:36
@felixweinberger
felixweinberger force-pushed the fweinberger/v2-bc-register-rawshape branch 2 times, most recently from 2972af1 to a1bf55a Compare April 16, 2026 16:11
@felixweinberger
felixweinberger marked this pull request as ready for review April 16, 2026 16:59
@felixweinberger
felixweinberger requested a review from a team as a code owner April 16, 2026 16:59
Comment thread packages/core/src/util/standardSchema.ts Outdated
Comment thread packages/server/src/server/mcp.ts
@felixweinberger
felixweinberger marked this pull request as draft April 16, 2026 18:55
@felixweinberger
felixweinberger force-pushed the fweinberger/v2-bc-register-rawshape branch from a1bf55a to 27e4ddf Compare April 16, 2026 19:57
@felixweinberger

Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread .changeset/register-rawshape-compat.md Outdated
Comment thread packages/server/src/server/mcp.ts
@felixweinberger

Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/core/src/util/standardSchema.ts Outdated
@felixweinberger
felixweinberger marked this pull request as ready for review April 17, 2026 10:11
Comment thread packages/core/src/util/standardSchema.ts Outdated
Comment thread packages/server/test/server/mcp.compat.test.ts Outdated
@felixweinberger
felixweinberger marked this pull request as draft April 17, 2026 10:45
@felixweinberger

Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/server/src/server/mcp.ts
Comment thread packages/server/src/server/mcp.ts Outdated
…spy; cover normalizeRawShapeSchema passthrough/undefined
@felixweinberger
felixweinberger force-pushed the fweinberger/v2-bc-register-rawshape branch from 688b282 to 1af9ed2 Compare April 17, 2026 12:42
@felixweinberger

Copy link
Copy Markdown
Contributor Author

@claude review

Comment on lines +12 to +18
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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/liststd.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 }) => );
  1. 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 vtrue.
  2. normalizeRawShapeSchema returns z.object({a: zm.string()}) — a classic v4 ZodObject. Constructs without throwing (lazy).
  3. Stored as tool.inputSchema.
  4. tools/liststandardSchemaToJsonSchema(wrapped, 'input')std.jsonSchema exists (classic v4 ZodObject has it) → calls std.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.jsonSchemaz.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.

Comment on lines +1 to +5
/**
* 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.
*/

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.ts so 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

  1. 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.
     */
  2. git log shows 0febd83 (creates zodCompat.ts and removes the zod import from standardSchema.ts) was committed before merge 9b7ee90.
  3. 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.
  4. At HEAD, packages/core/src/util/standardSchema.ts:9 reads import * as z from 'zod/v4'; and line 178 reads result = z.toJSONSchema(schema as unknown as z.ZodType, { target: 'draft-2020-12', io }) ....
  5. 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.

@felixweinberger
felixweinberger merged commit e15a8ef into main Apr 29, 2026
19 checks passed
@felixweinberger
felixweinberger deleted the fweinberger/v2-bc-register-rawshape branch April 29, 2026 15:34
felixweinberger added a commit that referenced this pull request Apr 29, 2026
felixweinberger added a commit that referenced this pull request Apr 29, 2026
felixweinberger added a commit that referenced this pull request Apr 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2-bc v2 backwards-compatibility series

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants