Skip to content

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

Merged
felixweinberger merged 16 commits intomainfrom
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 intomainfrom
fweinberger/v2-bc-register-rawshape

Conversation

@felixweinberger
Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger commented Apr 15, 2026

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
Copy link
Copy Markdown

changeset-bot Bot commented Apr 15, 2026

🦋 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
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 15, 2026

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

KKonstantinov and others added 2 commits April 24, 2026 17:42
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.
@felixweinberger felixweinberger marked this pull request as ready for review April 27, 2026 10:21
Comment thread packages/server/src/server/completable.ts
Copy link
Copy Markdown
Contributor

@KKonstantinov KKonstantinov left a comment

Choose a reason for hiding this comment

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

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.

Comment thread packages/core/src/util/standardSchema.ts Outdated
Comment thread packages/core/src/util/standardSchema.ts Outdated
felixweinberger and others added 2 commits April 27, 2026 12:19
…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.
Comment thread packages/core/test/util/standardSchema.test.ts Outdated
Comment thread packages/core/src/util/zodCompat.ts
…malizeRawShapeSchema guard to require ~standard.jsonSchema
Comment thread packages/core/src/util/zodCompat.ts Outdated
…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.
@felixweinberger felixweinberger force-pushed the fweinberger/v2-bc-register-rawshape branch from 068ff56 to a6b25ee Compare April 27, 2026 13:42
Comment thread packages/core/src/util/zodCompat.ts Outdated
Comment thread packages/core/src/util/zodCompat.ts
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

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.

Comment thread packages/core/src/util/zodCompat.ts Outdated
felixweinberger and others added 2 commits April 29, 2026 14:04
… 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.
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
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