From 27e4ddfeb31cd7d1f192d972ef2b43f51459bbc1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 15 Apr 2026 11:59:36 +0000 Subject: [PATCH 01/12] feat(compat): registerTool/registerPrompt accept raw Zod shape (auto-wrap with z.object) --- .changeset/register-rawshape-compat.md | 6 ++ packages/core/src/util/standardSchema.ts | 34 +++++++++ packages/server/src/server/mcp.ts | 72 ++++++++++++++++++- .../server/test/server/mcp.compat.test.ts | 54 ++++++++++++++ 4 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 .changeset/register-rawshape-compat.md create mode 100644 packages/server/test/server/mcp.compat.test.ts diff --git a/.changeset/register-rawshape-compat.md b/.changeset/register-rawshape-compat.md new file mode 100644 index 000000000..5a2a064e2 --- /dev/null +++ b/.changeset/register-rawshape-compat.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/server': patch +--- + +`registerTool`/`registerPrompt` accept a raw Zod shape (`{ field: z.string() }`) for `inputSchema`/`outputSchema`/`argsSchema` in addition to a wrapped Standard Schema. Raw shapes are auto-wrapped with `z.object()`. Both forms are first-class. diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index 9817dc39a..c136ba649 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -6,6 +6,8 @@ /* eslint-disable @typescript-eslint/no-namespace */ +import * as z from 'zod/v4'; + // Standard Schema interfaces — vendored from https://standardschema.dev (spec v1, Jan 2025) export interface StandardTypedV1 { @@ -136,6 +138,38 @@ export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSch return isStandardJSONSchema(schema) && isStandardSchema(schema); } +/** + * Detects a "raw shape" — a plain object whose values are Zod (or other + * Standard Schema) field schemas, e.g. `{ name: z.string() }`. Powers the + * auto-wrap in {@linkcode normalizeRawShapeSchema}. + * + * @internal + */ +export function isZodRawShape(obj: unknown): obj is Record { + 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)); +} + +/** + * Accepts either a {@linkcode StandardSchemaWithJSON} or a raw Zod shape + * `{ field: z.string() }` and returns a {@linkcode StandardSchemaWithJSON}. + * Raw shapes are wrapped with `z.object()` so the rest of the pipeline sees a + * uniform schema type; already-wrapped schemas pass through unchanged. + * + * @internal + */ +export function normalizeRawShapeSchema( + schema: StandardSchemaWithJSON | Record | undefined +): StandardSchemaWithJSON | undefined { + if (schema === undefined) return undefined; + if (isZodRawShape(schema)) { + return z.object(schema as z.ZodRawShape) as StandardSchemaWithJSON; + } + return schema; +} + // JSON Schema conversion /** diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 6c2699997..886fee692 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -30,6 +30,7 @@ import type { import { assertCompleteRequestPrompt, assertCompleteRequestResourceTemplate, + normalizeRawShapeSchema, promptArgumentsFromStandardSchema, ProtocolError, ProtocolErrorCode, @@ -873,6 +874,31 @@ export class McpServer { _meta?: Record; }, cb: ToolCallback + ): RegisteredTool; + /** @deprecated Wrap with `z.object({...})` instead. Raw-shape form: `inputSchema`/`outputSchema` may be a plain `{ field: z.string() }` record; it is auto-wrapped with `z.object()`. */ + registerTool( + name: string, + config: { + title?: string; + description?: string; + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + _meta?: Record; + }, + cb: LegacyToolCallback + ): RegisteredTool; + registerTool( + name: string, + config: { + title?: string; + description?: string; + inputSchema?: StandardSchemaWithJSON | ZodRawShape; + outputSchema?: StandardSchemaWithJSON | ZodRawShape; + annotations?: ToolAnnotations; + _meta?: Record; + }, + cb: ToolCallback | LegacyToolCallback ): RegisteredTool { if (this._registeredTools[name]) { throw new Error(`Tool ${name} is already registered`); @@ -884,8 +910,8 @@ export class McpServer { name, title, description, - inputSchema, - outputSchema, + normalizeRawShapeSchema(inputSchema), + normalizeRawShapeSchema(outputSchema), annotations, { taskSupport: 'forbidden' }, _meta, @@ -928,6 +954,27 @@ export class McpServer { _meta?: Record; }, cb: PromptCallback + ): RegisteredPrompt; + /** @deprecated Wrap with `z.object({...})` instead. Raw-shape form: `argsSchema` may be a plain `{ field: z.string() }` record; it is auto-wrapped with `z.object()`. */ + registerPrompt( + name: string, + config: { + title?: string; + description?: string; + argsSchema?: Args; + _meta?: Record; + }, + cb: LegacyPromptCallback + ): RegisteredPrompt; + registerPrompt( + name: string, + config: { + title?: string; + description?: string; + argsSchema?: StandardSchemaWithJSON | ZodRawShape; + _meta?: Record; + }, + cb: PromptCallback | LegacyPromptCallback ): RegisteredPrompt { if (this._registeredPrompts[name]) { throw new Error(`Prompt ${name} is already registered`); @@ -939,7 +986,7 @@ export class McpServer { name, title, description, - argsSchema, + normalizeRawShapeSchema(argsSchema), cb as PromptCallback, _meta ); @@ -1062,6 +1109,25 @@ export class ResourceTemplate { } } +/** + * 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; + +/** Infers the parsed-output type of a {@linkcode ZodRawShape}. */ +export type InferRawShape = { [K in keyof S]: StandardSchemaWithJSON.InferOutput }; + +/** {@linkcode ToolCallback} variant used when `inputSchema` is a {@linkcode ZodRawShape}. */ +export type LegacyToolCallback = Args extends ZodRawShape + ? (args: InferRawShape, ctx: ServerContext) => CallToolResult | Promise + : (ctx: ServerContext) => CallToolResult | Promise; + +/** {@linkcode PromptCallback} variant used when `argsSchema` is a {@linkcode ZodRawShape}. */ +export type LegacyPromptCallback = Args extends ZodRawShape + ? (args: InferRawShape, ctx: ServerContext) => GetPromptResult | Promise + : (ctx: ServerContext) => GetPromptResult | Promise; + export type BaseToolCallback< SendResultT extends Result, Ctx extends ServerContext, diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts new file mode 100644 index 000000000..63e64e34e --- /dev/null +++ b/packages/server/test/server/mcp.compat.test.ts @@ -0,0 +1,54 @@ +import { isStandardSchema } from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; +import { McpServer } from '../../src/index.js'; + +describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () => { + it('registerTool accepts a raw shape for inputSchema, auto-wraps, and does not warn', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const server = new McpServer({ name: 't', version: '1.0.0' }); + + server.registerTool('a', { inputSchema: { x: z.number() } }, async ({ x }) => ({ + content: [{ type: 'text' as const, text: String(x) }] + })); + server.registerTool('b', { inputSchema: { y: z.number() } }, async ({ y }) => ({ + content: [{ type: 'text' as const, text: String(y) }] + })); + + const tools = (server as unknown as { _registeredTools: Record })._registeredTools; + expect(Object.keys(tools)).toEqual(['a', 'b']); + // raw shape was wrapped into a Standard Schema (z.object) + expect(isStandardSchema(tools['a']?.inputSchema)).toBe(true); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('registerTool with z.object() inputSchema also works without warning', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const server = new McpServer({ name: 't', version: '1.0.0' }); + + server.registerTool('c', { inputSchema: z.object({ x: z.number() }) }, async ({ x }) => ({ + content: [{ type: 'text' as const, text: String(x) }] + })); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('registerPrompt accepts a raw shape for argsSchema and does not warn', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const server = new McpServer({ name: 't', version: '1.0.0' }); + + server.registerPrompt('p', { argsSchema: { topic: z.string() } }, async ({ topic }) => ({ + messages: [{ role: 'user' as const, content: { type: 'text' as const, text: topic } }] + })); + + const prompts = (server as unknown as { _registeredPrompts: Record })._registeredPrompts; + expect(Object.keys(prompts)).toContain('p'); + expect(isStandardSchema(prompts['p']?.argsSchema)).toBe(true); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); From 526613112f7608a4859758df58169b2fdc43bfe1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 16 Apr 2026 20:46:47 +0000 Subject: [PATCH 02/12] fix: isZodRawShape treats empty object as raw shape (matches v1) --- packages/core/src/util/standardSchema.ts | 4 ++-- .../core/test/util/standardSchema.test.ts | 22 ++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index c136ba649..45d0165fd 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -148,8 +148,8 @@ export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSch export function isZodRawShape(obj: unknown): obj is Record { 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)); + // [].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)); } /** diff --git a/packages/core/test/util/standardSchema.test.ts b/packages/core/test/util/standardSchema.test.ts index 6c3de99d7..63a584aab 100644 --- a/packages/core/test/util/standardSchema.test.ts +++ b/packages/core/test/util/standardSchema.test.ts @@ -1,6 +1,26 @@ import * as z from 'zod/v4'; -import { standardSchemaToJsonSchema } from '../../src/util/standardSchema.js'; +import { isZodRawShape, normalizeRawShapeSchema, standardSchemaToJsonSchema } from '../../src/util/standardSchema.js'; + +describe('isZodRawShape', () => { + test('treats empty object as a raw shape (matches v1)', () => { + expect(isZodRawShape({})).toBe(true); + }); + test('detects raw shape with zod fields', () => { + expect(isZodRawShape({ a: z.string() })).toBe(true); + }); + test('rejects a Standard Schema instance', () => { + expect(isZodRawShape(z.object({ a: z.string() }))).toBe(false); + }); +}); + +describe('normalizeRawShapeSchema', () => { + test('wraps empty raw shape into z.object({})', () => { + const wrapped = normalizeRawShapeSchema({}); + expect(wrapped).toBeDefined(); + expect(standardSchemaToJsonSchema(wrapped!, 'input').type).toBe('object'); + }); +}); describe('standardSchemaToJsonSchema', () => { test('emits type:object for plain z.object schemas', () => { From f2fdbe7381e42871658477b9d7416305126d5925 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 16 Apr 2026 21:28:00 +0000 Subject: [PATCH 03/12] docs: changeset wording aligns with @deprecated overloads (not first-class) --- .changeset/register-rawshape-compat.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/register-rawshape-compat.md b/.changeset/register-rawshape-compat.md index 5a2a064e2..bca0d9057 100644 --- a/.changeset/register-rawshape-compat.md +++ b/.changeset/register-rawshape-compat.md @@ -3,4 +3,4 @@ '@modelcontextprotocol/server': patch --- -`registerTool`/`registerPrompt` accept a raw Zod shape (`{ field: z.string() }`) for `inputSchema`/`outputSchema`/`argsSchema` in addition to a wrapped Standard Schema. Raw shapes are auto-wrapped with `z.object()`. Both forms are first-class. +`registerTool`/`registerPrompt` accept a raw Zod shape (`{ field: z.string() }`) for `inputSchema`/`outputSchema`/`argsSchema` in addition to a wrapped Standard Schema. Raw shapes are auto-wrapped with `z.object()`. The raw-shape overloads are `@deprecated`; prefer wrapping with `z.object()`. From 9576f20b2936bbd30646e6cea2748f3dda8ca9a9 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 16 Apr 2026 23:40:14 +0000 Subject: [PATCH 04/12] docs: clarify isZodRawShape only supports Zod values for auto-wrap --- packages/core/src/util/standardSchema.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index 45d0165fd..7bf83140b 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -139,9 +139,10 @@ export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSch } /** - * Detects a "raw shape" — a plain object whose values are Zod (or other - * Standard Schema) field schemas, e.g. `{ name: z.string() }`. Powers the - * auto-wrap in {@linkcode normalizeRawShapeSchema}. + * Detects a "raw shape" — a plain object whose values are Zod field schemas, + * e.g. `{ name: z.string() }`. Powers the auto-wrap in + * {@linkcode normalizeRawShapeSchema}, which wraps with `z.object()`, so only + * Zod values are supported even though the predicate accepts any Standard Schema. * * @internal */ From 0152b266fd4ab51de1144b77dd7bed9d1bae79f0 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 10:53:52 +0000 Subject: [PATCH 05/12] fix(compat): narrow ZodRawShape to Zod-only (detector + type); add outputSchema raw-shape test --- packages/core/src/util/standardSchema.ts | 16 +++++++++++----- packages/core/test/util/standardSchema.test.ts | 4 ++++ packages/server/src/server/mcp.ts | 8 +++++--- packages/server/test/server/mcp.compat.test.ts | 12 ++++++++++++ 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index 7bf83140b..4228bb25a 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -138,19 +138,25 @@ export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSch return isStandardJSONSchema(schema) && isStandardSchema(schema); } +function isZodSchema(v: unknown): v is z.ZodType { + if (typeof v !== 'object' || v === null) return false; + if ('_def' in v) return true; + return isStandardSchema(v) && (v as StandardSchemaV1)['~standard'].vendor === 'zod'; +} + /** * Detects a "raw shape" — a plain object whose values are Zod field schemas, * e.g. `{ name: z.string() }`. Powers the auto-wrap in * {@linkcode normalizeRawShapeSchema}, which wraps with `z.object()`, so only - * Zod values are supported even though the predicate accepts any Standard Schema. + * Zod values are supported. * * @internal */ -export function isZodRawShape(obj: unknown): obj is Record { +export function isZodRawShape(obj: unknown): obj is Record { 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)); + return Object.values(obj).every(v => isZodSchema(v)); } /** @@ -162,11 +168,11 @@ export function isZodRawShape(obj: unknown): obj is Record | undefined + schema: StandardSchemaWithJSON | Record | undefined ): StandardSchemaWithJSON | undefined { if (schema === undefined) return undefined; if (isZodRawShape(schema)) { - return z.object(schema as z.ZodRawShape) as StandardSchemaWithJSON; + return z.object(schema) as StandardSchemaWithJSON; } return schema; } diff --git a/packages/core/test/util/standardSchema.test.ts b/packages/core/test/util/standardSchema.test.ts index 63a584aab..a207121f0 100644 --- a/packages/core/test/util/standardSchema.test.ts +++ b/packages/core/test/util/standardSchema.test.ts @@ -12,6 +12,10 @@ describe('isZodRawShape', () => { test('rejects a Standard Schema instance', () => { expect(isZodRawShape(z.object({ a: z.string() }))).toBe(false); }); + test('rejects a shape with non-Zod Standard Schema fields', () => { + const nonZod = { '~standard': { version: 1, vendor: 'arktype', validate: () => ({ value: 'x' }) } }; + expect(isZodRawShape({ a: nonZod })).toBe(false); + }); }); describe('normalizeRawShapeSchema', () => { diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 886fee692..4caa125c4 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -40,6 +40,7 @@ import { validateStandardSchema } from '@modelcontextprotocol/core'; +import type * as z from 'zod/v4'; import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js'; import { getCompleter, isCompletable } from './completable.js'; @@ -1110,13 +1111,14 @@ export class ResourceTemplate { } /** - * A plain record of field schemas, e.g. `{ name: z.string() }`. Accepted by + * A plain record of Zod field schemas, e.g. `{ name: z.string() }`. Accepted by * `registerTool`/`registerPrompt` as a shorthand; auto-wrapped with `z.object()`. + * Zod schemas only — `z.object()` cannot wrap other Standard Schema libraries. */ -export type ZodRawShape = Record; +export type ZodRawShape = Record; /** Infers the parsed-output type of a {@linkcode ZodRawShape}. */ -export type InferRawShape = { [K in keyof S]: StandardSchemaWithJSON.InferOutput }; +export type InferRawShape = { [K in keyof S]: z.output }; /** {@linkcode ToolCallback} variant used when `inputSchema` is a {@linkcode ZodRawShape}. */ export type LegacyToolCallback = Args extends ZodRawShape diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index 63e64e34e..2d36978c4 100644 --- a/packages/server/test/server/mcp.compat.test.ts +++ b/packages/server/test/server/mcp.compat.test.ts @@ -24,6 +24,18 @@ describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () = warn.mockRestore(); }); + it('registerTool accepts a raw shape for outputSchema and auto-wraps it', () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + server.registerTool('out', { inputSchema: { n: z.number() }, outputSchema: { result: z.string() } }, async ({ n }) => ({ + content: [{ type: 'text' as const, text: String(n) }], + structuredContent: { result: String(n) } + })); + + const tools = (server as unknown as { _registeredTools: Record })._registeredTools; + expect(isStandardSchema(tools['out']?.outputSchema)).toBe(true); + }); + it('registerTool with z.object() inputSchema also works without warning', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const server = new McpServer({ name: 't', version: '1.0.0' }); From 1af9ed2d9aa0fb6d1c3e9d954d74087589f9188b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 12:41:43 +0000 Subject: [PATCH 06/12] test(compat): add e2e raw-shape tools/call test; drop vestigial warn-spy; cover normalizeRawShapeSchema passthrough/undefined --- .../core/test/util/standardSchema.test.ts | 7 +++ .../server/test/server/mcp.compat.test.ts | 63 ++++++++++++++----- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/packages/core/test/util/standardSchema.test.ts b/packages/core/test/util/standardSchema.test.ts index a207121f0..de081b975 100644 --- a/packages/core/test/util/standardSchema.test.ts +++ b/packages/core/test/util/standardSchema.test.ts @@ -24,6 +24,13 @@ describe('normalizeRawShapeSchema', () => { expect(wrapped).toBeDefined(); expect(standardSchemaToJsonSchema(wrapped!, 'input').type).toBe('object'); }); + test('passes through an already-wrapped Standard Schema unchanged', () => { + const schema = z.object({ a: z.string() }); + expect(normalizeRawShapeSchema(schema)).toBe(schema); + }); + test('returns undefined for undefined input', () => { + expect(normalizeRawShapeSchema(undefined)).toBeUndefined(); + }); }); describe('standardSchemaToJsonSchema', () => { diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index 2d36978c4..8cfd70b27 100644 --- a/packages/server/test/server/mcp.compat.test.ts +++ b/packages/server/test/server/mcp.compat.test.ts @@ -1,11 +1,11 @@ -import { isStandardSchema } from '@modelcontextprotocol/core'; +import type { JSONRPCMessage } from '@modelcontextprotocol/core'; +import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; import { describe, expect, it, vi } from 'vitest'; import * as z from 'zod/v4'; import { McpServer } from '../../src/index.js'; describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () => { - it('registerTool accepts a raw shape for inputSchema, auto-wraps, and does not warn', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + it('registerTool accepts a raw shape for inputSchema and auto-wraps it', () => { const server = new McpServer({ name: 't', version: '1.0.0' }); server.registerTool('a', { inputSchema: { x: z.number() } }, async ({ x }) => ({ @@ -19,9 +19,6 @@ describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () = expect(Object.keys(tools)).toEqual(['a', 'b']); // raw shape was wrapped into a Standard Schema (z.object) expect(isStandardSchema(tools['a']?.inputSchema)).toBe(true); - - expect(warn).not.toHaveBeenCalled(); - warn.mockRestore(); }); it('registerTool accepts a raw shape for outputSchema and auto-wraps it', () => { @@ -36,20 +33,18 @@ describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () = expect(isStandardSchema(tools['out']?.outputSchema)).toBe(true); }); - it('registerTool with z.object() inputSchema also works without warning', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + it('registerTool with z.object() inputSchema also works (passthrough, no auto-wrap)', () => { const server = new McpServer({ name: 't', version: '1.0.0' }); server.registerTool('c', { inputSchema: z.object({ x: z.number() }) }, async ({ x }) => ({ content: [{ type: 'text' as const, text: String(x) }] })); - expect(warn).not.toHaveBeenCalled(); - warn.mockRestore(); + const tools = (server as unknown as { _registeredTools: Record })._registeredTools; + expect(isStandardSchema(tools['c']?.inputSchema)).toBe(true); }); - it('registerPrompt accepts a raw shape for argsSchema and does not warn', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + it('registerPrompt accepts a raw shape for argsSchema', () => { const server = new McpServer({ name: 't', version: '1.0.0' }); server.registerPrompt('p', { argsSchema: { topic: z.string() } }, async ({ topic }) => ({ @@ -59,8 +54,48 @@ describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () = const prompts = (server as unknown as { _registeredPrompts: Record })._registeredPrompts; expect(Object.keys(prompts)).toContain('p'); expect(isStandardSchema(prompts['p']?.argsSchema)).toBe(true); + }); + + it('callback receives validated, typed args end-to-end via tools/call', async () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + let received: { x: number } | undefined; + server.registerTool('echo', { inputSchema: { x: z.number() } }, async args => { + received = args; + return { content: [{ type: 'text' as const, text: String(args.x) }] }; + }); + + const [client, srv] = InMemoryTransport.createLinkedPair(); + await server.connect(srv); + await client.start(); + + const responses: JSONRPCMessage[] = []; + client.onmessage = m => responses.push(m); + + await client.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'c', version: '1.0.0' } + } + } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', method: 'notifications/initialized' } as JSONRPCMessage); + await client.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'echo', arguments: { x: 7 } } + } as JSONRPCMessage); + + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true)); + + expect(received).toEqual({ x: 7 }); + const result = responses.find(r => 'id' in r && r.id === 2) as { result?: { content: Array<{ text: string }> } }; + expect(result.result?.content[0]?.text).toBe('7'); - expect(warn).not.toHaveBeenCalled(); - warn.mockRestore(); + await server.close(); }); }); From aba1d39980f436428703a6d17f788d78bd0d2c46 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 24 Apr 2026 15:15:25 +0000 Subject: [PATCH 07/12] feat(compat): widen completable() constraint to StandardSchemaV1 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. --- packages/server/src/server/completable.ts | 18 ++++++++--------- .../server/test/server/mcp.compat.test.ts | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/server/src/server/completable.ts b/packages/server/src/server/completable.ts index 2e651e932..82300f7df 100644 --- a/packages/server/src/server/completable.ts +++ b/packages/server/src/server/completable.ts @@ -1,19 +1,19 @@ -import type { StandardSchemaWithJSON } from '@modelcontextprotocol/core'; +import type { StandardSchemaV1 } from '@modelcontextprotocol/core'; export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable'); -export type CompleteCallback = ( - value: StandardSchemaWithJSON.InferInput, +export type CompleteCallback = ( + value: StandardSchemaV1.InferInput, context?: { arguments?: Record; } -) => StandardSchemaWithJSON.InferInput[] | Promise[]>; +) => StandardSchemaV1.InferInput[] | Promise[]>; -export type CompletableMeta = { +export type CompletableMeta = { complete: CompleteCallback; }; -export type CompletableSchema = T & { +export type CompletableSchema = T & { [COMPLETABLE_SYMBOL]: CompletableMeta; }; @@ -48,7 +48,7 @@ export type CompletableSchema = T & { * * @see {@linkcode server/mcp.McpServer.registerPrompt | McpServer.registerPrompt} for using completable schemas in prompt argument definitions */ -export function completable(schema: T, complete: CompleteCallback): CompletableSchema { +export function completable(schema: T, complete: CompleteCallback): CompletableSchema { Object.defineProperty(schema as object, COMPLETABLE_SYMBOL, { value: { complete } as CompletableMeta, enumerable: false, @@ -61,14 +61,14 @@ export function completable(schema: T, complet /** * Checks if a schema is completable (has completion metadata). */ -export function isCompletable(schema: unknown): schema is CompletableSchema { +export function isCompletable(schema: unknown): schema is CompletableSchema { return !!schema && typeof schema === 'object' && COMPLETABLE_SYMBOL in (schema as object); } /** * Gets the completer callback from a completable schema, if it exists. */ -export function getCompleter(schema: T): CompleteCallback | undefined { +export function getCompleter(schema: T): CompleteCallback | undefined { const meta = (schema as unknown as { [COMPLETABLE_SYMBOL]?: CompletableMeta })[COMPLETABLE_SYMBOL]; return meta?.complete as CompleteCallback | undefined; } diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index 8cfd70b27..1f4d1d4e4 100644 --- a/packages/server/test/server/mcp.compat.test.ts +++ b/packages/server/test/server/mcp.compat.test.ts @@ -3,6 +3,7 @@ import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION } from '@m import { describe, expect, it, vi } from 'vitest'; import * as z from 'zod/v4'; import { McpServer } from '../../src/index.js'; +import { completable } from '../../src/server/completable.js'; describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () => { it('registerTool accepts a raw shape for inputSchema and auto-wraps it', () => { @@ -56,6 +57,25 @@ describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () = expect(isStandardSchema(prompts['p']?.argsSchema)).toBe(true); }); + it('registerPrompt raw shape accepts completable() fields (v1 pattern)', () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + server.registerPrompt( + 'p', + { + argsSchema: { + language: completable(z.string(), v => ['typescript', 'python'].filter(l => l.startsWith(v))) + } + }, + async ({ language }) => ({ + messages: [{ role: 'user' as const, content: { type: 'text' as const, text: language } }] + }) + ); + + const prompts = (server as unknown as { _registeredPrompts: Record })._registeredPrompts; + expect(isStandardSchema(prompts['p']?.argsSchema)).toBe(true); + }); + it('callback receives validated, typed args end-to-end via tools/call', async () => { const server = new McpServer({ name: 't', version: '1.0.0' }); From 0febd83f7babf95bc3b268cb534010192b973438 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 27 Apr 2026 12:19:14 +0000 Subject: [PATCH 08/12] refactor(compat): move zod helpers to zodCompat.ts; throw on invalid 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> so .optional() fields produce ?: keys. - Changeset mentions the completable() constraint widening. --- .changeset/register-rawshape-compat.md | 2 + packages/core/src/index.ts | 1 + packages/core/src/util/standardSchema.ts | 41 -------------- packages/core/src/util/zodCompat.ts | 54 +++++++++++++++++++ .../core/test/util/standardSchema.test.ts | 6 ++- packages/server/src/server/mcp.ts | 2 +- .../server/test/server/mcp.compat.test.ts | 10 +++- 7 files changed, 72 insertions(+), 44 deletions(-) create mode 100644 packages/core/src/util/zodCompat.ts diff --git a/.changeset/register-rawshape-compat.md b/.changeset/register-rawshape-compat.md index bca0d9057..5f1f16784 100644 --- a/.changeset/register-rawshape-compat.md +++ b/.changeset/register-rawshape-compat.md @@ -4,3 +4,5 @@ --- `registerTool`/`registerPrompt` accept a raw Zod shape (`{ field: z.string() }`) for `inputSchema`/`outputSchema`/`argsSchema` in addition to a wrapped Standard Schema. Raw shapes are auto-wrapped with `z.object()`. The raw-shape overloads are `@deprecated`; prefer wrapping with `z.object()`. + +Also widens the `completable()` constraint from `StandardSchemaWithJSON` to `StandardSchemaV1` so v1's `completable(z.string(), fn)` continues to work. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e707d9939..e9132b40c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,6 +15,7 @@ export * from './types/index.js'; export * from './util/inMemory.js'; export * from './util/schema.js'; export * from './util/standardSchema.js'; +export * from './util/zodCompat.js'; // experimental exports export * from './experimental/index.js'; diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index 4228bb25a..9817dc39a 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -6,8 +6,6 @@ /* eslint-disable @typescript-eslint/no-namespace */ -import * as z from 'zod/v4'; - // Standard Schema interfaces — vendored from https://standardschema.dev (spec v1, Jan 2025) export interface StandardTypedV1 { @@ -138,45 +136,6 @@ export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSch return isStandardJSONSchema(schema) && isStandardSchema(schema); } -function isZodSchema(v: unknown): v is z.ZodType { - if (typeof v !== 'object' || v === null) return false; - if ('_def' in v) return true; - return isStandardSchema(v) && (v as StandardSchemaV1)['~standard'].vendor === 'zod'; -} - -/** - * Detects a "raw shape" — a plain object whose values are Zod field schemas, - * e.g. `{ name: z.string() }`. Powers the auto-wrap in - * {@linkcode normalizeRawShapeSchema}, which wraps with `z.object()`, so only - * Zod values are supported. - * - * @internal - */ -export function isZodRawShape(obj: unknown): obj is Record { - 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 => isZodSchema(v)); -} - -/** - * Accepts either a {@linkcode StandardSchemaWithJSON} or a raw Zod shape - * `{ field: z.string() }` and returns a {@linkcode StandardSchemaWithJSON}. - * Raw shapes are wrapped with `z.object()` so the rest of the pipeline sees a - * uniform schema type; already-wrapped schemas pass through unchanged. - * - * @internal - */ -export function normalizeRawShapeSchema( - schema: StandardSchemaWithJSON | Record | undefined -): StandardSchemaWithJSON | undefined { - if (schema === undefined) return undefined; - if (isZodRawShape(schema)) { - return z.object(schema) as StandardSchemaWithJSON; - } - return schema; -} - // JSON Schema conversion /** diff --git a/packages/core/src/util/zodCompat.ts b/packages/core/src/util/zodCompat.ts new file mode 100644 index 000000000..10c123a49 --- /dev/null +++ b/packages/core/src/util/zodCompat.ts @@ -0,0 +1,54 @@ +/** + * 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. + */ + +import * as z from 'zod/v4'; + +import type { StandardSchemaV1, StandardSchemaWithJSON } from './standardSchema.js'; +import { isStandardSchema } from './standardSchema.js'; + +function isZodSchema(v: unknown): v is z.ZodType { + if (typeof v !== 'object' || v === null) return false; + if ('_def' in v) return true; + return isStandardSchema(v) && (v as StandardSchemaV1)['~standard'].vendor === 'zod'; +} + +/** + * Detects a "raw shape" — a plain object whose values are Zod field schemas, + * e.g. `{ name: z.string() }`. Powers the auto-wrap in + * {@linkcode normalizeRawShapeSchema}, which wraps with `z.object()`, so only + * Zod values are supported. + * + * @internal + */ +export function isZodRawShape(obj: unknown): obj is Record { + 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 => isZodSchema(v)); +} + +/** + * Accepts either a {@linkcode StandardSchemaWithJSON} or a raw Zod shape + * `{ field: z.string() }` and returns a {@linkcode StandardSchemaWithJSON}. + * Raw shapes are wrapped with `z.object()` so the rest of the pipeline sees a + * uniform schema type; already-wrapped schemas pass through unchanged. + * + * @internal + */ +export function normalizeRawShapeSchema( + schema: StandardSchemaWithJSON | Record | undefined +): StandardSchemaWithJSON | undefined { + if (schema === undefined) return undefined; + if (isZodRawShape(schema)) { + return z.object(schema) as StandardSchemaWithJSON; + } + if (!isStandardSchema(schema)) { + throw new TypeError( + 'inputSchema/outputSchema/argsSchema must be a Standard Schema (e.g. z.object({...})) or a raw Zod shape ({ field: z.string() }).' + ); + } + return schema; +} diff --git a/packages/core/test/util/standardSchema.test.ts b/packages/core/test/util/standardSchema.test.ts index de081b975..dcafc977d 100644 --- a/packages/core/test/util/standardSchema.test.ts +++ b/packages/core/test/util/standardSchema.test.ts @@ -1,6 +1,7 @@ import * as z from 'zod/v4'; -import { isZodRawShape, normalizeRawShapeSchema, standardSchemaToJsonSchema } from '../../src/util/standardSchema.js'; +import { standardSchemaToJsonSchema } from '../../src/util/standardSchema.js'; +import { isZodRawShape, normalizeRawShapeSchema } from '../../src/util/zodCompat.js'; describe('isZodRawShape', () => { test('treats empty object as a raw shape (matches v1)', () => { @@ -31,6 +32,9 @@ describe('normalizeRawShapeSchema', () => { test('returns undefined for undefined input', () => { expect(normalizeRawShapeSchema(undefined)).toBeUndefined(); }); + test('throws TypeError for an invalid object that is neither raw shape nor Standard Schema', () => { + expect(() => normalizeRawShapeSchema({ a: 'not a zod schema' } as never)).toThrow(TypeError); + }); }); describe('standardSchemaToJsonSchema', () => { diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 4caa125c4..77e86f506 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -1118,7 +1118,7 @@ export class ResourceTemplate { export type ZodRawShape = Record; /** Infers the parsed-output type of a {@linkcode ZodRawShape}. */ -export type InferRawShape = { [K in keyof S]: z.output }; +export type InferRawShape = z.infer>; /** {@linkcode ToolCallback} variant used when `inputSchema` is a {@linkcode ZodRawShape}. */ export type LegacyToolCallback = Args extends ZodRawShape diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index 1f4d1d4e4..322b61535 100644 --- a/packages/server/test/server/mcp.compat.test.ts +++ b/packages/server/test/server/mcp.compat.test.ts @@ -1,8 +1,9 @@ import type { JSONRPCMessage } from '@modelcontextprotocol/core'; import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import * as z from 'zod/v4'; import { McpServer } from '../../src/index.js'; +import type { InferRawShape } from '../../src/server/mcp.js'; import { completable } from '../../src/server/completable.js'; describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () => { @@ -119,3 +120,10 @@ describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () = await server.close(); }); }); + +describe('InferRawShape', () => { + it('preserves optionality from .optional() as ?: keys', () => { + type S = InferRawShape<{ a: z.ZodString; b: z.ZodOptional }>; + expectTypeOf().toEqualTypeOf<{ a: string; b?: string | undefined }>(); + }); +}); From c75bc888f8982dd50a239f5782489e3779fd954c Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 27 Apr 2026 12:45:07 +0000 Subject: [PATCH 09/12] test(compat): move zod-compat tests to zodCompat.test.ts; tighten normalizeRawShapeSchema guard to require ~standard.jsonSchema --- packages/core/src/util/zodCompat.ts | 6 +-- .../core/test/util/standardSchema.test.ts | 35 ---------------- packages/core/test/util/zodCompat.test.ts | 42 +++++++++++++++++++ 3 files changed, 45 insertions(+), 38 deletions(-) create mode 100644 packages/core/test/util/zodCompat.test.ts diff --git a/packages/core/src/util/zodCompat.ts b/packages/core/src/util/zodCompat.ts index 10c123a49..ae94bb17b 100644 --- a/packages/core/src/util/zodCompat.ts +++ b/packages/core/src/util/zodCompat.ts @@ -7,7 +7,7 @@ import * as z from 'zod/v4'; import type { StandardSchemaV1, StandardSchemaWithJSON } from './standardSchema.js'; -import { isStandardSchema } from './standardSchema.js'; +import { isStandardSchema, isStandardSchemaWithJSON } from './standardSchema.js'; function isZodSchema(v: unknown): v is z.ZodType { if (typeof v !== 'object' || v === null) return false; @@ -45,9 +45,9 @@ export function normalizeRawShapeSchema( if (isZodRawShape(schema)) { return z.object(schema) as StandardSchemaWithJSON; } - if (!isStandardSchema(schema)) { + if (!isStandardSchemaWithJSON(schema)) { throw new TypeError( - 'inputSchema/outputSchema/argsSchema must be a Standard Schema (e.g. z.object({...})) or a raw Zod shape ({ field: z.string() }).' + 'inputSchema/outputSchema/argsSchema must be a Standard Schema with JSON Schema export (`~standard.jsonSchema`, e.g. z.object({...}) from zod >=4.2.0) or a raw Zod shape ({ field: z.string() }).' ); } return schema; diff --git a/packages/core/test/util/standardSchema.test.ts b/packages/core/test/util/standardSchema.test.ts index dcafc977d..6c3de99d7 100644 --- a/packages/core/test/util/standardSchema.test.ts +++ b/packages/core/test/util/standardSchema.test.ts @@ -1,41 +1,6 @@ import * as z from 'zod/v4'; import { standardSchemaToJsonSchema } from '../../src/util/standardSchema.js'; -import { isZodRawShape, normalizeRawShapeSchema } from '../../src/util/zodCompat.js'; - -describe('isZodRawShape', () => { - test('treats empty object as a raw shape (matches v1)', () => { - expect(isZodRawShape({})).toBe(true); - }); - test('detects raw shape with zod fields', () => { - expect(isZodRawShape({ a: z.string() })).toBe(true); - }); - test('rejects a Standard Schema instance', () => { - expect(isZodRawShape(z.object({ a: z.string() }))).toBe(false); - }); - test('rejects a shape with non-Zod Standard Schema fields', () => { - const nonZod = { '~standard': { version: 1, vendor: 'arktype', validate: () => ({ value: 'x' }) } }; - expect(isZodRawShape({ a: nonZod })).toBe(false); - }); -}); - -describe('normalizeRawShapeSchema', () => { - test('wraps empty raw shape into z.object({})', () => { - const wrapped = normalizeRawShapeSchema({}); - expect(wrapped).toBeDefined(); - expect(standardSchemaToJsonSchema(wrapped!, 'input').type).toBe('object'); - }); - test('passes through an already-wrapped Standard Schema unchanged', () => { - const schema = z.object({ a: z.string() }); - expect(normalizeRawShapeSchema(schema)).toBe(schema); - }); - test('returns undefined for undefined input', () => { - expect(normalizeRawShapeSchema(undefined)).toBeUndefined(); - }); - test('throws TypeError for an invalid object that is neither raw shape nor Standard Schema', () => { - expect(() => normalizeRawShapeSchema({ a: 'not a zod schema' } as never)).toThrow(TypeError); - }); -}); describe('standardSchemaToJsonSchema', () => { test('emits type:object for plain z.object schemas', () => { diff --git a/packages/core/test/util/zodCompat.test.ts b/packages/core/test/util/zodCompat.test.ts new file mode 100644 index 000000000..de5603114 --- /dev/null +++ b/packages/core/test/util/zodCompat.test.ts @@ -0,0 +1,42 @@ +import * as z from 'zod/v4'; + +import { standardSchemaToJsonSchema } from '../../src/util/standardSchema.js'; +import { isZodRawShape, normalizeRawShapeSchema } from '../../src/util/zodCompat.js'; + +describe('isZodRawShape', () => { + test('treats empty object as a raw shape (matches v1)', () => { + expect(isZodRawShape({})).toBe(true); + }); + test('detects raw shape with zod fields', () => { + expect(isZodRawShape({ a: z.string() })).toBe(true); + }); + test('rejects a Standard Schema instance', () => { + expect(isZodRawShape(z.object({ a: z.string() }))).toBe(false); + }); + test('rejects a shape with non-Zod Standard Schema fields', () => { + const nonZod = { '~standard': { version: 1, vendor: 'arktype', validate: () => ({ value: 'x' }) } }; + expect(isZodRawShape({ a: nonZod })).toBe(false); + }); +}); + +describe('normalizeRawShapeSchema', () => { + test('wraps empty raw shape into z.object({})', () => { + const wrapped = normalizeRawShapeSchema({}); + expect(wrapped).toBeDefined(); + expect(standardSchemaToJsonSchema(wrapped!, 'input').type).toBe('object'); + }); + test('passes through an already-wrapped Standard Schema unchanged', () => { + const schema = z.object({ a: z.string() }); + expect(normalizeRawShapeSchema(schema)).toBe(schema); + }); + test('returns undefined for undefined input', () => { + expect(normalizeRawShapeSchema(undefined)).toBeUndefined(); + }); + test('throws TypeError for an invalid object that is neither raw shape nor Standard Schema', () => { + expect(() => normalizeRawShapeSchema({ a: 'not a zod schema' } as never)).toThrow(TypeError); + }); + test('throws TypeError for a Standard Schema without JSON Schema export', () => { + const noJson = { '~standard': { version: 1, vendor: 'x', validate: () => ({ value: {} }) } }; + expect(() => normalizeRawShapeSchema(noJson as never)).toThrow(/~standard\.jsonSchema/); + }); +}); From a6b25ee38d22fd4e12f1e520295a6fbe3e18c3bf Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 27 Apr 2026 13:35:59 +0000 Subject: [PATCH 10/12] fix(compat): reject Zod v3 fields in raw-shape auto-wrap with actionable 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. --- packages/core/src/util/zodCompat.ts | 30 ++++++++++++++++++----- packages/core/test/util/zodCompat.test.ts | 16 ++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/core/src/util/zodCompat.ts b/packages/core/src/util/zodCompat.ts index ae94bb17b..59788fabc 100644 --- a/packages/core/src/util/zodCompat.ts +++ b/packages/core/src/util/zodCompat.ts @@ -6,13 +6,26 @@ import * as z from 'zod/v4'; -import type { StandardSchemaV1, StandardSchemaWithJSON } from './standardSchema.js'; +import type { StandardSchemaWithJSON } from './standardSchema.js'; import { isStandardSchema, isStandardSchemaWithJSON } from './standardSchema.js'; -function isZodSchema(v: unknown): v is z.ZodType { - if (typeof v !== 'object' || v === null) return false; - if ('_def' in v) return true; - return isStandardSchema(v) && (v as StandardSchemaV1)['~standard'].vendor === 'zod'; +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; +} + +function looksLikeZodV3(v: unknown): boolean { + // v3 schemas have `_def.typeName` (e.g. 'ZodString') and no `_zod`. + return ( + typeof v === 'object' && + v !== null && + !('_zod' in v) && + '_def' in v && + typeof (v as { _def?: { typeName?: unknown } })._def?.typeName === 'string' + ); } /** @@ -27,7 +40,7 @@ export function isZodRawShape(obj: unknown): obj is Record { 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 => isZodSchema(v)); + return Object.values(obj).every(v => isZodV4Schema(v)); } /** @@ -45,6 +58,11 @@ export function normalizeRawShapeSchema( if (isZodRawShape(schema)) { return z.object(schema) as StandardSchemaWithJSON; } + if (typeof schema === 'object' && !isStandardSchema(schema) && Object.values(schema).some(v => looksLikeZodV3(v))) { + throw new TypeError( + 'Raw-shape inputSchema/outputSchema/argsSchema fields must be Zod v4 schemas. Got a Zod v3 field schema. Import from `zod/v4` (or upgrade your zod import), or wrap with `z.object({...})` yourself.' + ); + } if (!isStandardSchemaWithJSON(schema)) { throw new TypeError( 'inputSchema/outputSchema/argsSchema must be a Standard Schema with JSON Schema export (`~standard.jsonSchema`, e.g. z.object({...}) from zod >=4.2.0) or a raw Zod shape ({ field: z.string() }).' diff --git a/packages/core/test/util/zodCompat.test.ts b/packages/core/test/util/zodCompat.test.ts index de5603114..5ea37d76a 100644 --- a/packages/core/test/util/zodCompat.test.ts +++ b/packages/core/test/util/zodCompat.test.ts @@ -17,8 +17,21 @@ describe('isZodRawShape', () => { const nonZod = { '~standard': { version: 1, vendor: 'arktype', validate: () => ({ value: 'x' }) } }; expect(isZodRawShape({ a: nonZod })).toBe(false); }); + test('rejects a shape with Zod v3 fields (only v4 is wrappable)', () => { + expect(isZodRawShape({ a: mockZodV3String() })).toBe(false); + }); }); +// Minimal structural mock of a Zod v3 schema: has `_def.typeName` and +// `~standard.vendor === 'zod'` (zod >=3.24), but no `_zod`. +function mockZodV3String(): unknown { + return { + _def: { typeName: 'ZodString', checks: [], coerce: false }, + '~standard': { version: 1, vendor: 'zod', validate: (v: unknown) => ({ value: v }) }, + parse: (v: unknown) => v + }; +} + describe('normalizeRawShapeSchema', () => { test('wraps empty raw shape into z.object({})', () => { const wrapped = normalizeRawShapeSchema({}); @@ -39,4 +52,7 @@ describe('normalizeRawShapeSchema', () => { const noJson = { '~standard': { version: 1, vendor: 'x', validate: () => ({ value: {} }) } }; expect(() => normalizeRawShapeSchema(noJson as never)).toThrow(/~standard\.jsonSchema/); }); + test('throws actionable TypeError for a raw shape with Zod v3 fields', () => { + expect(() => normalizeRawShapeSchema({ a: mockZodV3String() } as never)).toThrow(/Zod v4 schemas.*Got a Zod v3 field schema/); + }); }); From b5854c19325a66f0e4d003128e4a7b9514734439 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 27 Apr 2026 15:13:17 +0000 Subject: [PATCH 11/12] fix(compat): require plain-object prototype in isZodRawShape; null-guard v3-detection branch --- packages/core/src/util/zodCompat.ts | 8 ++++++-- packages/core/test/util/zodCompat.test.ts | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/core/src/util/zodCompat.ts b/packages/core/src/util/zodCompat.ts index 59788fabc..c890952ed 100644 --- a/packages/core/src/util/zodCompat.ts +++ b/packages/core/src/util/zodCompat.ts @@ -39,7 +39,11 @@ function looksLikeZodV3(v: unknown): boolean { export function isZodRawShape(obj: unknown): obj is Record { 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). + // Require a plain object literal: rejects arrays, Date, Map, RegExp, class instances, etc. + // Object.create(null) is also accepted. + const proto = Object.getPrototypeOf(obj); + if (proto !== Object.prototype && proto !== null) return false; + // [].every() is true, so an empty plain object is a valid raw shape (matches v1). return Object.values(obj).every(v => isZodV4Schema(v)); } @@ -58,7 +62,7 @@ export function normalizeRawShapeSchema( if (isZodRawShape(schema)) { return z.object(schema) as StandardSchemaWithJSON; } - if (typeof schema === 'object' && !isStandardSchema(schema) && Object.values(schema).some(v => looksLikeZodV3(v))) { + if (typeof schema === 'object' && schema !== null && !isStandardSchema(schema) && Object.values(schema).some(v => looksLikeZodV3(v))) { throw new TypeError( 'Raw-shape inputSchema/outputSchema/argsSchema fields must be Zod v4 schemas. Got a Zod v3 field schema. Import from `zod/v4` (or upgrade your zod import), or wrap with `z.object({...})` yourself.' ); diff --git a/packages/core/test/util/zodCompat.test.ts b/packages/core/test/util/zodCompat.test.ts index 5ea37d76a..af7d2b90b 100644 --- a/packages/core/test/util/zodCompat.test.ts +++ b/packages/core/test/util/zodCompat.test.ts @@ -20,6 +20,18 @@ describe('isZodRawShape', () => { test('rejects a shape with Zod v3 fields (only v4 is wrappable)', () => { expect(isZodRawShape({ a: mockZodV3String() })).toBe(false); }); + test('rejects non-plain objects with no own-enumerable properties', () => { + expect(isZodRawShape([])).toBe(false); + expect(isZodRawShape([z.string()])).toBe(false); + expect(isZodRawShape(new Date())).toBe(false); + expect(isZodRawShape(new Map())).toBe(false); + expect(isZodRawShape(/regex/)).toBe(false); + }); + test('accepts a null-prototype plain object', () => { + const o = Object.create(null); + o.a = z.string(); + expect(isZodRawShape(o)).toBe(true); + }); }); // Minimal structural mock of a Zod v3 schema: has `_def.typeName` and @@ -55,4 +67,7 @@ describe('normalizeRawShapeSchema', () => { test('throws actionable TypeError for a raw shape with Zod v3 fields', () => { expect(() => normalizeRawShapeSchema({ a: mockZodV3String() } as never)).toThrow(/Zod v4 schemas.*Got a Zod v3 field schema/); }); + test('throws the intended TypeError (not Object.values crash) for null input', () => { + expect(() => normalizeRawShapeSchema(null as never)).toThrow(/must be a Standard Schema/); + }); }); From 27e2c4b1817c88f636d5ad8a551f696bc5ca6ddd Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 29 Apr 2026 14:04:11 +0000 Subject: [PATCH 12/12] fix(compat): pass StandardSchema without ~standard.jsonSchema through 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. --- packages/core/src/util/zodCompat.ts | 10 +++++++--- packages/core/test/util/zodCompat.test.ts | 20 ++++++++++++++++++-- packages/server/src/server/mcp.ts | 2 +- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/core/src/util/zodCompat.ts b/packages/core/src/util/zodCompat.ts index c890952ed..3bb208809 100644 --- a/packages/core/src/util/zodCompat.ts +++ b/packages/core/src/util/zodCompat.ts @@ -7,7 +7,7 @@ import * as z from 'zod/v4'; import type { StandardSchemaWithJSON } from './standardSchema.js'; -import { isStandardSchema, isStandardSchemaWithJSON } from './standardSchema.js'; +import { isStandardSchema } from './standardSchema.js'; function isZodV4Schema(v: unknown): v is z.ZodType { // `_zod` is the v4 internal namespace property. Zod v3 schemas have `_def` @@ -67,10 +67,14 @@ export function normalizeRawShapeSchema( 'Raw-shape inputSchema/outputSchema/argsSchema fields must be Zod v4 schemas. Got a Zod v3 field schema. Import from `zod/v4` (or upgrade your zod import), or wrap with `z.object({...})` yourself.' ); } - if (!isStandardSchemaWithJSON(schema)) { + if (!isStandardSchema(schema)) { throw new TypeError( - 'inputSchema/outputSchema/argsSchema must be a Standard Schema with JSON Schema export (`~standard.jsonSchema`, e.g. z.object({...}) from zod >=4.2.0) or a raw Zod shape ({ field: z.string() }).' + 'inputSchema/outputSchema/argsSchema must be a Standard Schema (e.g. z.object({...})) or a raw Zod shape ({ field: z.string() }).' ); } + // Any StandardSchema passes through; standardSchemaToJsonSchema owns the per-vendor + // handling for schemas without `~standard.jsonSchema` (zod 4.0-4.1 fallback, zod 3 + // and non-zod errors). Gating on `~standard.jsonSchema` here would unreachably + // front-run that fallback. return schema; } diff --git a/packages/core/test/util/zodCompat.test.ts b/packages/core/test/util/zodCompat.test.ts index af7d2b90b..cf48be3d3 100644 --- a/packages/core/test/util/zodCompat.test.ts +++ b/packages/core/test/util/zodCompat.test.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest'; import * as z from 'zod/v4'; import { standardSchemaToJsonSchema } from '../../src/util/standardSchema.js'; @@ -60,9 +61,24 @@ describe('normalizeRawShapeSchema', () => { test('throws TypeError for an invalid object that is neither raw shape nor Standard Schema', () => { expect(() => normalizeRawShapeSchema({ a: 'not a zod schema' } as never)).toThrow(TypeError); }); - test('throws TypeError for a Standard Schema without JSON Schema export', () => { + test('passes through a Standard Schema without `~standard.jsonSchema` (per-vendor handling deferred to standardSchemaToJsonSchema)', () => { const noJson = { '~standard': { version: 1, vendor: 'x', validate: () => ({ value: {} }) } }; - expect(() => normalizeRawShapeSchema(noJson as never)).toThrow(/~standard\.jsonSchema/); + expect(normalizeRawShapeSchema(noJson as never)).toBe(noJson); + }); + test('passes through a zod 4.0-4.1 schema so standardSchemaToJsonSchema can apply its z.toJSONSchema fallback', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const real = z.object({ a: z.string() }); + // Simulate zod 4.0-4.1: shadow `~standard` with `jsonSchema` removed, keep `_zod` intact. + const { jsonSchema: _drop, ...stdNoJson } = real['~standard'] as unknown as Record; + void _drop; + Object.defineProperty(real, '~standard', { value: { ...stdNoJson, vendor: 'zod' }, configurable: true }); + + const normalized = normalizeRawShapeSchema(real); + expect(normalized).toBe(real); + const json = standardSchemaToJsonSchema(normalized!, 'input'); + expect(json.type).toBe('object'); + expect((json.properties as Record)?.a).toBeDefined(); + warn.mockRestore(); }); test('throws actionable TypeError for a raw shape with Zod v3 fields', () => { expect(() => normalizeRawShapeSchema({ a: mockZodV3String() } as never)).toThrow(/Zod v4 schemas.*Got a Zod v3 field schema/); diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 77e86f506..fb45fd5db 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -39,8 +39,8 @@ import { validateAndWarnToolName, validateStandardSchema } from '@modelcontextprotocol/core'; - import type * as z from 'zod/v4'; + import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js'; import { getCompleter, isCompletable } from './completable.js';