Skip to content

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

Open
felixweinberger wants to merge 1 commit intomainfrom
fweinberger/v2-bc-register-rawshape
Open

feat(compat): registerTool/registerPrompt accept raw Zod shape, auto-wrap with z.object()#1901
felixweinberger wants to merge 1 commit intomainfrom
fweinberger/v2-bc-register-rawshape

Conversation

@felixweinberger
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 and warns.

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 and warns.

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. Removed in v3.

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: a1bf55a

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: a1bf55a

@felixweinberger felixweinberger force-pushed the fweinberger/v2-bc-register-rawshape branch 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 on lines +148 to +152
export function isZodRawShape(obj: unknown): obj is Record<string, StandardSchemaV1> {
if (typeof obj !== 'object' || obj === null) return false;
if (isStandardSchema(obj)) return false;
const values = Object.values(obj);
return values.length > 0 && values.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.

🔴 An empty raw shape {} is not auto-wrapped: isZodRawShape requires values.length > 0, so normalizeRawShapeSchema({}) returns {} as-is, and tools/list then throws TypeError: Cannot read properties of undefined (reading 'jsonSchema'). Since {} satisfies the new ZodRawShape overload type and v1 accepted inputSchema: {} for no-arg tools, drop the values.length > 0 guard ([].every(...) is vacuously true) so empty shapes wrap to z.object({}).

Extended reasoning...

What the bug is

isZodRawShape() (packages/core/src/util/standardSchema.ts:148-152) returns false for an empty object {} because of the values.length > 0 clause. Consequently normalizeRawShapeSchema({}) skips the auto-wrap branch and returns {} unchanged, cast to StandardSchemaWithJSON. But {} is not a valid Standard Schema — it has no '~standard' property — so any downstream consumer that dereferences schema['~standard'] will throw.

The code path that triggers it

  1. User calls server.registerTool('ping', { inputSchema: {} }, async () => ...). This type-checks via the new raw-shape overload because {} satisfies ZodRawShape = Record<string, StandardSchemaWithJSON> (an empty record is assignable to any Record<string, T>).
  2. registerTool calls normalizeRawShapeSchema({}) (mcp.ts:910).
  3. Inside normalizeRawShapeSchema: {} is not undefined, and isZodRawShape({}) evaluates Object.values({}) → [], then [].length > 0 → false, so it returns false. The function falls through and returns {} typed as StandardSchemaWithJSON.
  4. The tool is stored with inputSchema: {} (truthy).
  5. A client sends tools/list. The handler (mcp.ts) checks tool.inputSchema ? standardSchemaToJsonSchema(tool.inputSchema, 'input') : EMPTY_OBJECT_JSON_SCHEMA. Since {} is truthy, it calls standardSchemaToJsonSchema({}).
  6. standardSchemaToJsonSchema executes schema['~standard'].jsonSchema[io](...). {}['~standard'] is undefined, so reading .jsonSchema throws TypeError: Cannot read properties of undefined (reading 'jsonSchema').

The same crash occurs on tools/call via validateToolInput → validateStandardSchema → schema['~standard'].validate, and on prompts/list/prompts/get for registerPrompt('p', { argsSchema: {} }, ...).

Why existing code doesn't prevent it

The only guards before the crash site are schema === undefined (in normalizeRawShapeSchema) and the truthiness check tool.inputSchema ? ... : EMPTY_OBJECT_JSON_SCHEMA (in the tools/list handler). {} passes both. There is no isStandardSchemaWithJSON validation between normalizeRawShapeSchema and standardSchemaToJsonSchema.

Why this matters for the PR's stated goal

This PR explicitly targets v1 compat for raw shapes. v1's ZodRawShape was z.ZodRawShape = { [k: string]: ZodTypeAny }, which admits {}, and registerTool('x', { inputSchema: {} }, cb) was a valid v1 pattern for declaring a no-args tool. The new TypeScript overload also accepts {}. So the compat shim accepts this input at the type level but crashes at runtime — exactly the kind of migration footgun the PR is meant to eliminate.

Step-by-step proof

const server = new McpServer({ name: 't', version: '1.0.0' });
server.registerTool('ping', { inputSchema: {} }, async () => ({
  content: [{ type: 'text', text: 'pong' }]
}));
// type-checks ✓

// Simulate tools/list:
// isZodRawShape({})            → values=[], 0>0 false → returns false
// normalizeRawShapeSchema({})  → returns {} (not undefined, not raw shape)
// tool.inputSchema = {}        → truthy
// standardSchemaToJsonSchema({}) → ({})['~standard'].jsonSchema.input(...)
//                              → TypeError: Cannot read properties of undefined

Fix

Drop the values.length > 0 guard — Array.prototype.every on an empty array is vacuously true, so isZodRawShape({}) will return true and {} will be wrapped as z.object({}), which serializes to { type: 'object', properties: {} } (matching EMPTY_OBJECT_JSON_SCHEMA):

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

Alternatively, special-case Object.keys(schema).length === 0 in normalizeRawShapeSchema to return undefined (so the existing EMPTY_OBJECT_JSON_SCHEMA fallback applies). Either is a one-line change.

Comment on lines +1112 to +1129
/**
* A plain record of field schemas, e.g. `{ name: z.string() }`. Accepted by
* `registerTool`/`registerPrompt` as a shorthand; auto-wrapped with `z.object()`.
*/
export type ZodRawShape = Record<string, StandardSchemaWithJSON>;

/** Infers the parsed-output type of a {@linkcode ZodRawShape}. */
export type InferRawShape<S extends ZodRawShape> = { [K in keyof S]: StandardSchemaWithJSON.InferOutput<S[K]> };

/** {@linkcode ToolCallback} variant used when `inputSchema` is a {@linkcode ZodRawShape}. */
export type LegacyToolCallback<Args extends ZodRawShape | undefined> = Args extends ZodRawShape
? (args: InferRawShape<Args>, ctx: ServerContext) => CallToolResult | Promise<CallToolResult>
: (ctx: ServerContext) => CallToolResult | Promise<CallToolResult>;

/** {@linkcode PromptCallback} variant used when `argsSchema` is a {@linkcode ZodRawShape}. */
export type LegacyPromptCallback<Args extends ZodRawShape | undefined> = Args extends ZodRawShape
? (args: InferRawShape<Args>, ctx: ServerContext) => GetPromptResult | Promise<GetPromptResult>
: (ctx: ServerContext) => GetPromptResult | Promise<GetPromptResult>;
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 PR's intent is internally inconsistent: the changeset says "Both forms are first-class" and the tests assert console.warn is not called, yet the PR description says "auto-wraps and warns" / "@deprecated shim. Removed in v3", and the new public types are named LegacyToolCallback/LegacyPromptCallback. Please pick one: either (a) restore the deprecation warning + @deprecated JSDoc and reword the changeset, or (b) drop the Legacy prefix from the type names. Relatedly, these types are not re-exported from packages/server/src/index.ts — fine if they're a deprecated shim, but if first-class they should sit alongside ToolCallback/PromptCallback.

Extended reasoning...

What's inconsistent

There are four artifacts in this PR that disagree about whether raw-shape support is a first-class feature or a deprecated v1-compat shim:

Artifact Says
PR description (twice) "auto-wraps raw shapes and warns" / "additive @deprecated shim. Removed in v3"
Changeset (.changeset/register-rawshape-compat.md) "Both forms are first-class."
Tests (mcp.compat.test.ts) All 3 cases explicitly assert expect(warn).not.toHaveBeenCalled()
Exported type names (mcp.ts:1122,1127) LegacyToolCallback / LegacyPromptCallback

The code matches the changeset (no warning, both accepted equally), but the type names match the PR description (legacy/deprecated). The PR description and changeset directly contradict each other.

Step-by-step proof

  1. normalizeRawShapeSchema in packages/core/src/util/standardSchema.ts:163-171 contains no console.warn call — it just wraps and returns.
  2. mcp.compat.test.ts:23,35,51 each spy on console.warn and assert it was not called — so the absence of a warning is intentional and locked in by tests.
  3. The new overload JSDoc at mcp.ts:878 and mcp.ts:958 says only "Raw-shape form: ... auto-wrapped" — no @deprecated tag.
  4. Yet the callback types at mcp.ts:1122,1127 are exported as LegacyToolCallback/LegacyPromptCallback — "Legacy" signals deprecation to anyone reading the .d.ts.
  5. The changeset (which becomes the published CHANGELOG entry) promises "Both forms are first-class."

So a consumer reading the changelog will see "first-class", but a consumer reading the generated types will see "Legacy". And a maintainer reading the PR description will expect a warning that doesn't exist.

Why this matters

The changeset text ships to npm as the changelog. The type names ship in the published .d.ts. Having these two disagree ("first-class" vs "Legacy*") sends mixed signals to consumers about whether they should migrate off raw shapes. And if the warning was intended (per the PR description) but accidentally dropped, that's a behavioral gap — users won't be nudged toward the v2-native form before v3 removes the shim.

Secondary: index.ts re-exports

packages/server/src/index.ts uses explicit named exports and currently re-exports ToolCallback/PromptCallback but not ZodRawShape/InferRawShape/LegacyToolCallback/LegacyPromptCallback. Whether that's correct depends on the answer above:

  • If first-class: parity suggests they should be re-exported alongside ToolCallback/PromptCallback so users can type a callback variable before passing it.
  • If a deprecated shim: omitting them from the package index is the right minimalist call (overload resolution + inference covers the common case, and there's no reason to grow the public surface for a shim).

I'm not asserting the omission is wrong — just that it should be a deliberate consequence of resolving the intent question, not an accident.

Suggested fix

Pick a lane:

Option A — deprecated shim (matches PR description):

  • Add console.warn in normalizeRawShapeSchema (or at the registerTool/registerPrompt call sites).
  • Add @deprecated to the raw-shape overload JSDoc.
  • Reword the changeset to drop "first-class" and say "accepted for v1 compatibility; prefer z.object()".
  • Update the tests to expect the warning.
  • Keep the Legacy* names; leave them out of index.ts.

Option B — first-class (matches changeset + current code):

  • Rename LegacyToolCallbackRawShapeToolCallback (and likewise for prompt).
  • Update the PR description to drop "warns" and "@deprecated".
  • Consider re-exporting the new types from packages/server/src/index.ts.

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