Skip to content

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

Draft
felixweinberger wants to merge 4 commits intomainfrom
fweinberger/v2-bc-register-rawshape
Draft

feat(compat): registerTool/registerPrompt accept raw Zod shape, auto-wrap with z.object()#1901
felixweinberger wants to merge 4 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: 9576f20

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: 9576f20

@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 on lines +148 to +153
export function isZodRawShape(obj: unknown): obj is Record<string, StandardSchemaV1> {
if (typeof obj !== 'object' || obj === null) return false;
if (isStandardSchema(obj)) return false;
// [].every() is true, so an empty object is a valid raw shape (matches v1).
return Object.values(obj).every(v => isStandardSchema(v) || (typeof v === 'object' && v !== null && '_def' 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.

🟡 The raw-shape detector and types advertise broader Standard Schema support than the implementation can deliver: ZodRawShape = Record<string, StandardSchemaWithJSON> and isZodRawShape's isStandardSchema(v) branch both accept ArkType/Valibot fields, but z.object() only works with actual Zod types — so { a: type('string') } type-checks, passes the guard, gets wrapped, and then throws on tools/list/tools/call. Since this is a Zod-only v1-compat shim, drop the isStandardSchema(v) disjunct (the '_def' in v check covers Zod v3 and v4) and tighten the JSDoc/type to say Zod-only.

Extended reasoning...

What the bug is

Three artifacts in this PR advertise that the raw-shape overload accepts any Standard Schema field, not just Zod:

Artifact Location Says
Public type mcp.tsexport type ZodRawShape = Record<string, StandardSchemaWithJSON> Any StandardSchemaWithJSON field is accepted (ArkType, Valibot, …)
Runtime guard standardSchema.ts:152.every(v => isStandardSchema(v) || …) Any value with ~standard.validate passes
JSDoc standardSchema.ts:142-143 — "Zod (or other Standard Schema) field schemas" Explicitly documents non-Zod support

But the implementation at standardSchema.ts:168 does z.object(schema as z.ZodRawShape), and Zod v4's z.object() requires every shape value to be a ZodType — at parse time and JSON-schema-generation time it walks Zod internals (._zod / ._def) on each field. The as z.ZodRawShape cast hides a real type incompatibility that the runtime guard does not enforce.

Code path that triggers it

import { type } from 'arktype';
server.registerTool('x', { inputSchema: { a: type('string') } }, async ({ a }) => );
  1. Type-checks: type('string') implements StandardSchemaWithJSON, so { a: type('string') } satisfies ZodRawShape = Record<string, StandardSchemaWithJSON> and resolves to the new @deprecated overload.
  2. normalizeRawShapeSchemaisZodRawShape({a: type('string')}): not itself a StandardSchema; Object.values[type('string')]; isStandardSchema(type('string'))true → guard returns true.
  3. Wrapped as z.object({ a: type('string') } as z.ZodRawShape) — constructs without throwing.
  4. Stored on the registered tool as inputSchema.
  5. tools/liststandardSchemaToJsonSchema(inputSchema, 'input') → Zod's ~standard.jsonSchema.input() walks the shape and reads .def on the ArkType field → TypeError: Cannot read properties of undefined (reading 'def').
  6. tools/callvalidateStandardSchema → Zod's ~standard.validate()Invalid element at key 'a': expected a Zod schema.

(One verifier reproduced both errors empirically against the repo's zod/v4.)

Why existing code doesn't prevent it

The only gate between "user passed a raw shape" and "hand it to z.object()" is isZodRawShape, and its predicate is isStandardSchema(v) || (… && '_def' in v). The first disjunct is satisfied by every Standard Schema implementation, so the guard admits exactly the inputs that z.object() cannot handle. The cast on the next line silences the compiler.

Impact

A type-checks-but-crashes footgun on a public overload. Practical likelihood is low: this is a @deprecated v1-compat shim and v1 was Zod-only, so the target audience (v1 migrators) only has Zod fields; ArkType/Valibot users would naturally pass type({...}) / v.object({...}) (a full StandardSchema object), which hits the non-deprecated overload and bypasses auto-wrap entirely. But the type signature, the runtime check, and the JSDoc all actively advertise the broken path.

Step-by-step proof

  1. isStandardSchema(arktypeString) → has ['~standard'].validatetrue.
  2. isZodRawShape({a: arktypeString}) → not null/object ✓, not itself StandardSchema ✓, [arktypeString].every(v => isStandardSchema(v) || …)true.
  3. normalizeRawShapeSchema returns z.object({a: arktypeString} as z.ZodRawShape).
  4. z.object constructs lazily; no error yet.
  5. standardSchemaToJsonSchema(wrapped, 'input') → Zod iterates shape, dereferences arktypeString._zod.def (or equivalent) → undefined → throws.

Fix

One-line tightening — drop the isStandardSchema(v) disjunct so the guard requires Zod's _def marker (present on both Zod v3 and v4 schemas; Zod v4 also has ~standard, so Zod fields still pass):

return Object.values(obj).every(v => typeof v === 'object' && v \!== null && '_def' in v);

And update the JSDoc to drop "(or other Standard Schema)". Optionally narrow export type ZodRawShape to something Zod-specific (or at least note in its JSDoc that fields must be Zod schemas because they're handed to z.object()).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 9576f20.

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.

1 participant